前言

了解vue的双向数据绑定就会知道,在数据发生变化的时候,会触发劫持数据后设置的setter,在setter中,会触发Dep的notify, notify则会调用所有watchers(订阅者)的update,从而触发视图的更新,但其实watchers的update过程并没有那么简单。思考这样一个问题,在vue中,多次设置data里的同一个key的值,视图渲染几次?其实答案很明显,计算虚拟dom, 渲染的过程是很消耗性能的,vue肯定也会在这一个地方做优化,那vue又是如何实现多次设置同一个值只渲染一次呢?答案如标题所示,用的是异步更新队列。

queueWatcher

先来看下watcher.update做了什么:

  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      // 同步直接更新
      this.run()
    } else {
      // 默认异步,先放到异步队列
      queueWatcher(this)
    }
  }

主要就是判断是否同步,默认异步,把update操作放进queueWatcher当中。再来看看,queueWatcher又做了什么:

 export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 判断该观察者是否存在,如果存在了相同的id则不push进queue
  if (has[id] == null) {
    has[id] = true
    // 如果还没flushing,则把watcher放进queue中
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      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(flushSchedulerQueue)
    }
  }
}

这里是为什么多次设置值,只会取一个的关键,可以看到在queueWatcher的过程中,有一个has[id]的判断, 这样就能保证异步更新队列里同一个watcher只有一个,之后执行watch.run的时候就只拿最后获取的值。当然到这里还没完,看到这里会有一个疑问,异步更新队列什么时候执行,触发视图更新?答案就是代码中的nextTick,来看下nextTick做了什么:

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)
    }
  })
  // 把要执行的回调包装成macro或者micro
  if (!pending) {
    // 一个标记位,标记等待状态(即函数已经被推入任务队列或者主线程,已经在等待当前栈执行完毕去执行)
    pending = true
    // 我们可以理解成所有用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
 */
// 了解js的事件循环机制的话就能知道,以下宏任务的优先级是:
// setImmediate > MessageChannel > setTimeout/setInterval
// 如果当前环境支持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 {
  /* 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做兼容性处理
    if (isIOS) setTimeout(noop)
  }
} else {
  // 降级
  microTimerFunc = macroTimerFunc
}

可以看出来,nextTick的作用就是把callback转到异步执行,了解js的事件循环可以知道,无论把callback转成了宏任务还是微任务,都会在当前调用栈完成后执行,所以nextTick最终的目的是延迟callback到当前调用栈执行完以后执行。这也是为什么多次设值的时候,不会在中途就触发更新的原因,因为设值是在当前执行栈调用的过程,异步更新队列执行即视图更新的执行,被放在了宏任务或者微任务的等待队列中等待执行。最后再来看下,延迟执行的函数flushSchedulerQueue做的是什么:

flushSchedulerQueue

function flushSchedulerQueue () {
  flushing = true
  let watcher, id

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child)
  // 2. A component's user watchers are run before its render watcher (because
  //    user watchers are created before the render watcher)
  // 3. If a component is destroyed during a parent component's watcher run,
  //    its watchers can be skipped.
  // 给queue排序,这样做可以保证:
  // 1.组件更新的顺序是从父组件到子组件的顺序,因为父组件总是比子组件先创建。
  // 2.一个组件的user watchers比render watcher先运行,因为user watchers往往比render watcher更早创建
  // 3.如果一个组件在父组件watcher运行期间被销毁,它的watcher执行将被跳过。
  queue.sort((a, b) => a.id - b.id)

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  // 此处不缓存queue的length,因为在循环过程中queue依然可能被添加watcher导致length长度的改变
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    id = watcher.id
    has[id] = null
    // 主要目的,执行watch的run,触发视图的更新
    watcher.run()
    // in dev build, check and stop circular updates.
    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
      }
    }
  }

  // keep copies of post queues before resetting state
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()
  // 把waiting重置,允许执行下一个nextTick
  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}

这个就是最终触发视图更新的函数,通过watch.run获取最终data对应的值,赋值给视图,从而触发视图的更新,最后执行完再把waiting设回false,允许下一次nextTick的执行。

最后的问题

var vm = new Vue({
    el: '#example',
    data: {
        msg: 'begin',
    },
    mounted () {
      this.msg = 'end'
      console.log('1')
      setTimeout(() => { // macroTask
         console.log('3')
      }, 0)
      Promise.resolve().then(function () { //microTask
        console.log('promise!')
      })
      this.$nextTick(function () {
        console.log('2')
      })
  }
})

如上问题在vue的2.5.16版本中,最终输出结果会是1 2 promise 3,有一种说法,是能不能用promise取代$nextTick,这种说法在特定情况是行得通的, 在此例子当中,queueWathcernextTick中会判定为微任务,this.msg的执行比promise快,所以会先输出2,再输出promise, 这时候就能用promise代替this.$nextTick获取最新的值。但是如果是放在事件监听上(v-on)执行就不一样了,这时候nextTick会被判定为宏任务,所以会先执行promise, 再输出2,因此不能在promise中拿到最新的值。