在第三篇vue0.11版本源码阅读系列三:指令编译里我们知道如果某个属性的值变化了,会调用依赖该属性的watcherupdate方法:

p.update = function () {
  if (!config.async || config.debug) {
    this.run()
  } else {
    batcher.push(this)
  }
}

它没有直接调用指令的update方法,而是交给了batcher,本篇来看一下这个batcher做了什么。

顾名思义,batcher是批量的意思,所以就是批量更新,为什么要批量更新呢,先看一下下面的情况:

<div v-if="show">我出来了</div>
<div v-if="show && true">我也是</div>
window.vm.show = true
window.vm.show = false

比如有两个指令依赖同一个属性或者连续修改某个属性,如果不进行批量异步更新,那么就会多次修改dom,这显然是没必要的,看下面两个动图能更直观的感受到:

没有进行批量异步更新的时候:

2021-01-12-17-01-46

进行了批量异步更新:

2021-01-12-17-02-21

能清晰的发现通过异步更新能跳过中间不必要的渲染以达到优化性能的效果。

接下来看一下具体实现,首先是push函数:

// 定义了两个队列,一个用来存放用户的watcher,一个用来存放指令更新的watcher
var queue = []
var userQueue = []
var has = {}
var waiting = false
var flushing = false
exports.push = function (job) {
  // job就是watcher实例
  var id = job.id
  // 在没有flushing的情况下has[id]用来跳过同一个watcher的重复添加
  if (!id || !has[id] || flushing) {
    has[id] = 1
    // 首先要说明的是通过$watch方法或者watch选项生成的watcher代表是用户的,user属性为true
    // 这里注释说在执行任务中用户的watcher可能会触发非user的指令更新,所以要立即更新这个被触发的指令,否则flushing这个变量是不需要的
    if (flushing && !job.user) {
      job.run()
      return
    }
    // 根据指令的类型添加到不同的队列里
    ;(job.user ? userQueue : queue).push(job)
    // 上个队列未被清空前不会创建新队列
    if (!waiting) {
      waiting = true
      _.nextTick(flush)
    }
  }
}

push方法做的事情是把watcher添加到队列quene里,然后如果没有扔过flushnextTick或者上次扔给nextTickflush方法已经被执行了,就再给它一个。

flush方法用来遍历队列里的watcher并调用其run方法,run方法最终会调用指令的update方法来更新页面。

function flush () {
  flushing = true
  run(queue)
  run(userQueue)
  // 清空队列和复位变量
  reset()
}
function run (queue) {
  // 循环执行watcher实例的run方法,run方法里会遍历该watcher实例的指令队列并执行指令的update方法
  for (var i = 0; i < queue.length; i++) {
    queue[i].run()
  }
}

接下来就是nextTick方法了:

exports.nextTick = (function () {
  var callbacks = []
  var pending = false
  var timerFunc
  function handle () {
    pending = false
    var copies = callbacks.slice(0)
    callbacks = []
    for (var i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }
  // 支持MutationObserver接口的话使用MutationObserver
  if (typeof MutationObserver !== 'undefined') {
    var counter = 1
    var observer = new MutationObserver(handle)
    var textNode = document.createTextNode(counter)
    observer.observe(textNode, {
      characterData: true// 设为 true 表示监视指定目标节点或子节点树中节点所包含的字符数据的变化
    })
    timerFunc = function () {
      counter = (counter + 1) % 2// counter会在0和1两者循环变化
      textNode.data = counter// 节点变化会触发回调handle,
    }
  } else {// 否则使用定时器
    timerFunc = setTimeout
  }
  return function (cb, ctx) {
    var func = ctx
      ? function () { cb.call(ctx) }
      : cb
    callbacks.push(func)
    if (pending) return
    pending = true
    timerFunc(handle, 0)
  }
})()

这是个自执行函数,一般用来定义并保存一些局部变量,返回了一个函数,就是nextTick方法本法了,flush方法会被pushcallbacks数组里,我们常用的方法this.$nextTick(() => {xxxx})也会把回调添加到这个数组里,这里也有一个变量pending来控制重复添加的问题,最后添加到事件循环的队列里的是handle方法。

批量很容易理解,都放到一个队列里,最后一起执行就是批量执行了,但是要理解MutationObserver的回调或者setTimeout的回调为什么能异步调用就需要先来了解一下JavaScript语言里的事件循环Event Loop的原理了。

简单的说就是因为JavaScript是单线程的,所以任务需要排队进行执行,前一个执行完了才能执行后面一个,但有些任务比较耗时而且没必要等着,所以可以先放一边,先执行后面的,等到了可以执行了再去执行它,比如有些IO操作,像常见的鼠标键盘事件注册、Ajax请求、settimeout定时器、Promise回调等。所以会存在两个队列,一个是同步队列,也就是主线程,另一个是异步队列,刚才提到的那些事件的回调如果可以被执行了都会被放在异步队列里,当主线程上的任务执行完毕后会把异步队列的任务取过来进行执行,所以同步代码总是在异步代码之前执行,执行完了后又会去检查异步队列,这样不断循环就是Event Loop

但是异步任务里其实还是分两种,一种叫宏任务,常见的为:setTimeoutsetInterval,另一种叫微任务,常见的如:PromiseMutationObserver。微任务会在宏任务之前执行,即使宏任务的回调先被添加到队列里。

现在可以来分析一下异步更新的原理,就以开头提到的例子来说:

<div v-if="show">我出来了</div>
<div v-if="show && true">我也是</div>
window.vm.show = true
window.vm.show = false

因为有两个指令都依赖了show,表达式不一样,所以会有两个watcher,这两个watcher都会被show属性的dep收集,所以每修改一次show的值都会触发这两个watcher的更新,也就是会调两次batcher.push(this)方法,第一次调用后会执行_.nextTick(flush)注册一个回调,连续两次修改show的值,会调用四次上述提到的batcher.push(this)方法,因为重复添加的被过滤掉了,所以最后会有两个watcher被添加到队列里,以上这些操作都是同步任务,所以是连续被执行完的,等这些同步任务都被执行完了后就会把刚才注册的回调handle拿过来执行,也就是会一次性执行刚才添加的两个watcher

image-20210112200127418

以上就是vue异步更新的全部内容。


街角小林
886 声望773 粉丝