• Vue异步更新的原理是什么?
  • 发布于 2个月前
  • 545 热度
    0 评论
这篇文章主要介绍的就是 Vue 异步更新相关的原理。本篇的核心理解起来没有响应式原理难,重点就是两个字队列。

异步更新
为什么需要异步更新?
this.a = 1
this.b = 2
this.c = 3
this.a = 5
this.a = 6
想想嘛,根据 Vue 响应式原理,响应式数据在赋值时运行 setter,会触发视图更新,但是就像上面例子中连续赋值的场景可以说十分常见了,在 React 倒是可以写在一个 setState 里,但是 Vue 没有这种方法。总不能每次更新数据都立即给你刷新一次页面,如果每次赋值都跑一趟 render、patch,那整个页面不是卡得不行吗?解决这个问题的关键就是先把需要运行的更新函数都存入队列(而且,在加入队列时,相同的函数不会重复加入),再异步运行队列。

相关变量
在正式分析 Vue 的异步队列原理之前,先看看实现队列使用的这些变量:
var circular = {} // 开发环境检测循环引用
var queue = [] // watcher 队列
var activatedChildren = [] // 堆代码 duidaima.com
var has = {} // 是否已经加入队列
var waiting = false // 可以理解为是否在等待 flushing
var flushing = false // 是否已经开始处理队列
var index = 0 // 队列当前运行到哪里
你也许十分好奇,为什么会有 waiting 和 flushing 这么相似的两个变量呢?开始我也很疑惑,是否正在 flushing,是否等待 flushing(waiting),虽然还是有细微的区别:
1.在 waiting 时,异步任务未开始

2.在 flushing 时,已经确定开始队列任务


开始时间有细微不同,但是他们重置的时机(resetSchedulerState)是一致的,似乎是没有分两个变量的必要,所以我暂时把这理解成可以让代码更自文档吧。说回队列,这个系统存在着两条队列。既然上篇说到 queueWatcher 会将需要更新的 watcher 放入队列,下次一并更新。现在就先讲 watcher 的队列,具体分析 queueWatcher 到底进行了怎样的操作。

queueWatcher
function queueWatcher(watcher) {
  var id = watcher.id
  if (has[id] == null) {
    has[id] = true // 标记 watcher 已添加到队列
    if (!flushing) {
      // 队列还没开始更新,直接推入队列即可
      queue.push(watcher)
    } else {
      // 队列正在处理中时,Vue 的做法是直接把新的 watcher 插到运行到的最新位置
      // 这蕴含着隐藏逻辑
      // 已经在前面运行过的 watcher 这时候会立即再在下一个运行
      var 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)
    }
  }
}
最后的 nextTick(flushSchedulerQueue) 就是把运行 watcher 队列放入 nextTick 队列中(虽然没有传参,但是 flushSchedulerQueue 可以读取 queueWatcher 处理的 queue)。短短的这一句函数调用,连接了两个重点:flushSchedulerQueue 是处理 watcher 队列的核心,而 nextTick 是 Vue 异步渲染的核心。

flushSchedulerQueue
根据官方注释,queue.sort 对队列进行排序,是为了保证:
1.父组件先于子组件
2.用户定义的 watcher 函数先于渲染 watcher
3.如果组件在父组件被 destroy,可以跳过这个 watcher(通过 watcher 的 active 属性)
function flushSchedulerQueue() {
  flushing = true
  var watcher, id

  queue.sort(function(a, b) {
    return a.id - b.id
  })

  // queue 可变长,queue.length 不能缓存
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    id = watcher.id
    has[id] = null // 已经处理的 watcher 从 has 去除
    watcher.run()
  }

  // keep copies of post queues before resetting state
  var activatedQueue = activatedChildren.slice()
  var updatedQueue = queue.slice()

  resetSchedulerState() // 重置队列相关变量

  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)
}
简单来说 flushSchedulerQueue 就是用于处理 queueWatcher 编排好的 queue。这里提出一个简单的实践问题,在 watch 某个值的回调函数中,可以访问最新的 DOM 吗?

答案就藏在上面的代码中:在 flushSchedulerQueue 函数对 queue 的循环处理中,queue 仍然会接受 queueWatcher push 的 watcher,这主要用于用户自定的 watch 函数或组件间触发的更新,就不安排到下一个循环了,直接插入 queue 按顺序更新。

所以,大家不要以为 watch 一个值的变化,在回调函数里就能拿到最新的 DOM,因为他们是运行在同一 tick 的(而且组件渲染函数因为 sort 总运行在最后)。

nextTick
var nextTick = (function() {
  var callbacks = []
  var pending = false
  var timerFunc

  function nextTickHandler() {
    pending = false
    var copies = callbacks.slice(0)
    callbacks.length = 0
    for (var i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }

  if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = function() {
      setImmediate(nextTickHandler)
    }
  } else if (
    typeof MessageChannel !== 'undefined' &&
    (isNative(MessageChannel) ||
      // PhantomJS
      MessageChannel.toString() === '[object MessageChannelConstructor]')
  ) {
    var channel = new MessageChannel()
    var port = channel.port2
    channel.port1.onmessage = nextTickHandler
    timerFunc = function() {
      port.postMessage(1)
    }
  } else if (typeof Promise !== 'undefined' && isNative(Promise)) {
    // use microtask in non-DOM environments, e.g. Weex
    var p = Promise.resolve()
    timerFunc = function() {
      p.then(nextTickHandler)
    }
  } else {
    // fallback to setTimeout
    timerFunc = function() {
      setTimeout(nextTickHandler, 0)
    }
  }

  return function queueNextTick(cb, ctx) {
    var _resolve
    callbacks.push(function() {
      if (cb) {
        try {
          cb.call(ctx)
        } catch (e) {
          handleError(e, ctx, 'nextTick')
        }
      } else if (_resolve) {
        _resolve(ctx)
      }
    })
    if (!pending) {
      pending = true
      timerFunc()
    }
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise(function(resolve, reject) {
        _resolve = resolve
      })
    }
  }
})()
先解释一下 Vue 制造异步运行的方法吧——
An asynchronous deferring mechanism. In pre 2.4, we used to use microtasks (Promise/MutationObserver) but microtasks actually has too high a priority and fires in between supposedly sequential events (e.g. #4521, #6690) or even between bubbling of the same event (#6566). Technically setImmediate should be the ideal choice, but it’s not available everywhere; and the only polyfill that consistently queues the callback after all DOM events triggered in the same loop is by using MessageChannel.

根据官方注释,最开始 Vue 使用 Promise 等微任务更新队列,但是发现更新时机不太对,在不该插入的时间点插入更新,后来进行了修改。

这里 Vue 提供了 4 种制造异步调用的方式:
1.setImmediate
2.MessageChannel,IE 10 或以上均可使用,十分意外,不查不知道,MessageChannel 的可用范围居然比 Promise 还大
3.Promise,注释写用于 Weex
4.setTimeout 0,IE 10 以下
看回整个 nextTick 归根到底是什么?

从代码中看到这是一个立即执行函数,并返回一个 queueNextTick 函数。所以实际上我们使用的是 queueNextTick(cb, ctx)。(不过其实我也不是很懂为什么这里要用闭包包裹 callbacks、timerFunc 等变量而不直接放到外面)

在 nextTick(flushSchedulerQueue) 情况下,flushSchedulerQueue 就是回调函数 cb。

首先 cb 会被推入 callbacks,这是异步运行的队列,和 watcher 队列不是一回事
根据 !pending 的条件,pending 时会直接跳过 timerFunc 运行,直至上一次 nextTickHandler 完成
再顺带一提,我们平常用的 $nextTick 其实跟 nextTick 函数就是一个东西:
Vue.prototype.$nextTick = function(fn) {
  return nextTick(fn, this)
}

总结
两个队列:
1.queueWatcher 的 queue

2.nextTick 的 callbacks


queue 是比较自由的,方便在更新组件时及时新增需要修改的组件。虽然 callbacks 看起来是个队列,也是个数组,但是在 waiting 为 true 时并不会往 callbacks 塞东西,callbacks 应该是一直保持只有一个函数在排队的状态。(原理上应该是这样,但是不知道如何验证,如果有错误请指正)

流程:
1.异步更新的作用是防止重复运行更新函数,使运行更流畅
2.setter 触发 watcher 后,回调函数不会立即运行,而是通过 queueWatcher 函数把 watcher 加入队列 queue
3.在空闲时,queueWatcher 函数运行 nextTick(flushSchedulerQueue),把 queue 放到下一个 tick 运行
4.flushSchedulerQueue 函数运行 watcher 队列的函数
5.nextTick 就跟平常用的 $nextTick 一样,就是把队列运行放到下一 tick
6.Vue 制造 next tick 的方法有 4 种:setImmediate、MessageChannel、Promise、setTimeout
用户评论