Vue2.6x源码解析(四):异步更新队列

系列文章:

本节将深入理解Vue的异步更新原理。

根据Vue官方文档:Vue在更新dom的时候是异步执行的,但其实Vue中很多的回调【比如watch的回调、组件更新、nextTick】都是异步执行的,下面我们先理解一下整体的异步更新流程,然后我们再详细的讲解每个步骤:

  • 修改响应式数据。
  • 在响应式数据的setter中触发dep.notify()
  • 循环depsubs属性列表,通知subs中的每个watcher实例执行自身的update更新方法。
  • watcher实例的update方法会默认执行异步操作queueWatcher(this),将自身实例推入到一个 queue队列中。然后会立即执行一次nextTick(flushSchedulerQueue)方法,将刷新 queue 队列flushSchedulerQueue方法添加到 callbacks 数组。注意: 一次tick任务,刷新队列的调度方法只会被推入callbacks 一次。只有在本轮任务执行完成之后,才会重置waiting状态,在下轮dom更新任务中重新添加一次刷新队列的方法。每一轮的dom更新nextTick(flushSchedulerQueue)只会执行一次。
  • nextTick内部将cb回调任务推入callbacks 数组后,会立即执行timerFunc()方法,将flushCallbacks函数添加到浏览器的微任务队列【默认采用的Promise.then】。注意: 同上面一样,一轮dom更新中只会执行一次timerFunc()方法,微任务队列也只会有一个flushCallbacks函数,需要处理的所有回调任务都存储在callbacks数组中,在本次宏任务中,会一直向callbacks数组中添加任务,直到本次宏任务结束,开始执行微任务队列,即执行flushCallbacks方法处理任务。
  • 最后在执行flushCallbacks方法的时候,就会循环执行callbacks数组中的每个cb函数,即冲刷队列的flushSchedulerQueue方法和用户传入的nextTick回调。用户传入的nextTick回调比较简单,重点关注flushSchedulerQueue方法。注意: callbacks 数组中只会存在一个刷新队列flushSchedulerQueue任务和多个用户传入的nextTick任务。并且flushSchedulerQueue默认都是第一个开始执行,并且负责dom更新的watcher实例存储在queue中,而flushSchedulerQueue方法就是专门用于处理queue队列。所以用户传入的nextTick任务才会在 DOM 更新完成后被调用。
  • flushSchedulerQueue方法会循环调用queue队列中的每个watcher实例的run方法,实际上最终就是在run内部执行每个watcher的cb/getter函数,比如用户定义的watch的回调函数,组件更新render watcher的回调函数即updateComponent方法。

下面我们将仔细分析每一个步骤,深入理解每个执行过程:

1,notify

修改一个响应式数据:

this.count = 1;

setter中触发依赖:

// defineReactive 修改响应式数据,触发依赖
set: function reactiveSetter (newVal) {
  dep.notify()
}

继续查看notify源码:循环subs依赖列表,执行每个watcher实例自身的update更新方法:

notify () {
  // 复制一份依赖列表(subs里面都是watcher)
  const subs = this.subs.slice()
  // 所有依赖:循环触发watcher实例的更新方法
  for (let i = 0, l = subs.length; i < l; i++) {
    // 也就是Watcher中的update方法。
    subs[i].update()
  }
}

继续查看update源码:

update () {
  if (this.lazy) {
    // 计算属性使用
    this.dirty = true
  } else if (this.sync) {
    // 同步执行回调
    this.run()
  } else {
    // 异步执行回调:将当前watcher实例推送到队列
    # vue默认都是异步执行回调,可以查看前面的解析
    queueWatcher(this)
  }
}

2,queueWatcher

继续查看queueWatcher源码:

// watcher队列
// 推入一个watcher实例到queue队列,跳过has[id]重复的watcher
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 这里用has[id]第一次是undefined, 非全等的情况下:Boolean(undefined == null)为true
  if (has[id] == null) {
    // 给has对象定义一个属性,每一个watcher.id都是唯一的
    has[id] = true
    if (!flushing) {
      # 将watcher实例添加到queue队列
      queue.push(watcher)
    } else {
      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函数放进一个callbacks数组中
      nextTick(flushSchedulerQueue)
    }
  }
}

根据queueWatcher源码可知,这个方法主要有两个作用:

  • 第一个就是将传入的watcher实例推入到queue队列。
  • 第二个作用就是执行nextTick(flushSchedulerQueue)方法,而要执行第二个方法,得根据waiting变量的状态。

flushSchedulerQueue方法:专门用于处理queue队列中的任务。

// 默认为false
let waiting = false
if (!waiting) {
  waiting = true
  // 在一轮dom更新中只会被执行一次
  nextTick(flushSchedulerQueue)
}

根据上面的代码我们可以看出:第一次执行queueWatcher(this)后,waiting状态被锁住,后续执行queueWatcher(this),将不会再执行nextTick方法,只会向queue队列中继续添加watcher实例。

waiting的重置在flushSchedulerQueue方法中处理,后面我们再分析。

3,nextTick

下面我们继续查看nextTick源码:

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 推送任务:调用一次nextTick,就会像callbacks中推入一个任务
  // 这里将传入的cb回调函数,包装了一层,成为一个可以处理error的函数
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
​
  if (!pending) {
    pending = true
    # 添加到微任务队列
    timerFunc()
  }
}

callbacks是一个回调任务数组,前面我们执行nextTick(flushSchedulerQueue)后,就会将flushSchedulerQueue函数添加到callbacks数组。注意: nextTick不是直接将cb回调函数添加到数组中,而是包裹了一层箭头函数,这是为了方便执行回调函数的异常处理。

同时根据上面nextTick的源码,我们可以发现它和queueWatcher的源码思想非常类似,其内部都是两个作用:

  • 将参数推入到目标数组。
  • 根据状态锁执行一次目标方法。

所以这里的timerFunc同样只会执行一次:

// 第一次执行 
# 在一次宏任务中nextTick/timerFunc都只会被执行一次,在dom更新完成后解锁状态
queueWatcher(this) => nextTick(flushSchedulerQueue) => timerFunc()

timerFunc方法非常重要,是Vue异步执行回调的核心,根据外部环境来进行设置:

// 默认是采用Promise.then的微任务队列实现异步更新
timerFunc = () => {
  Promise.resolve().then(flushCallbacks)

timerFunc()一调用就会触发Promise.then(),将flushCallbacks方法添加到【微任务队列】。

按照环境的支持程度优先级:

  • Promise 微任务
  • MutationObserver 微任务
  • setImmediate 宏任务
  • setTimeout 宏任务

timerFunc()方法的执行目的:是将flushCallbacks添加到微任务队列。

4,flushCallbacks

下面我们继续查看flushCallbacks源码:

function flushCallbacks () {
  pending = false
  // 复制callbacks数组:slice(0)可简写为.slice()
  const copies = callbacks.slice(0)
  callbacks.length = 0
  # 循环执行copies中的每个cb回调任务
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

可以看出flushCallbacks方法内部很简单,就是循环执行callbacks数组中的每个回调任务。

其实到这里我们就已经可以总结出Vue的异步更新原理: 一句话理解就是利用Promise.then()flushCallbacks方法添加到微任务队列,执行flushCallbacks方法,循环callbacks数组处理所有的回调任务。

根据前面我们也知道callbacks数组中只有两种回调任务:flushSchedulerQueue以及用户自定义的nextTick回调。用户传入的回调函数比较简单,下面我们深入分析flushSchedulerQueue源码:

5,flushSchedulerQueue

下面我们继续查看flushSchedulerQueue源码:

# 冲刷queue队列
function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id
​
  // flush队列前先排序
  // 队列中watcher根据watcher.id 从小到大排序
  queue.sort((a, b) => a.id - b.id)
​
  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    // 取出每个watcher
    watcher = queue[index]
    if (watcher.before) {
      // 如果是组件watcher,触发组件的beforeMount钩子
      watcher.before()
    }
    id = watcher.id
    // 清除标记
    has[id] = null
    # 执行watcher实例中的cb/getter函数
    watcher.run()
  }
​
  // keep copies of post queues before resetting state
  // 保留队列副本
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()
  # 重置状态
  resetSchedulerState()
​
  // call component updated and activated hooks
  // 触发生命周期钩子
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)
​
  // devtool hook
  /* istanbul ignore if */
  // 组件更新完成后,刷新开发者工具
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}

flushSchedulerQueue方法非常重要:用户定义的watch回调以及组件更新的updateComponent方法都在flushSchedulerQueue方法内部执行,flushSchedulerQueue方法核心作用就是处理queue队列中的任务

根据上面的源码可知,flushSchedulerQueue方法内部首先将queue队列任务按照watcher.id从小到大排序,然后循环队列中的watcher调用watcher.run()方法。

注意: watcher.id一定是renderWatcher最大,因为renderWatcher是组件初始化最后才创建的,也就是说负责组件更新渲染的回调函数是最后才执行的。

我们继续回到watcher.run()方法:

// class Watcher
run () {
    if (this.active) {
      // 执行get方法,内部会调用watcher.getter【组件的updateComponent函数就存储在getter中】
      const value = this.get()
      if (value !== this.value || isObject(value) || this.deep) {
        // 取出旧值
        const oldValue = this.value
        // 设置新值
        this.value = value
        // 判断是不是用户定义的watcher,比如watch ,如果是就需要用可以处理error的方法来处理cb
        if (this.user) {
          const info = `callback for watcher "${this.expression}"`
          invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
        } else {
          // 内部生成的watcher,直接调用cb
          this.cb.call(this.vm, value, oldValue)
        }
      }
    }
 }

可以看到run方法的作用:就是调用watcher自身的cb/getter函数,所以最终用户定义watch监听回调或者组件渲染更新的回调都会在这里执行。

在处理完所有queue队列任务后,还有一个比较重要的处理就是重置几个变量的状态,这里为了下一次的更新使用:

// 重置调度程序的状态
function resetSchedulerState () {
  index = queue.length = activatedChildren.length = 0
  has = {}
  if (process.env.NODE_ENV !== 'production') {
    circular = {}
  }
  waiting = flushing = false
}

最后触发updated生命周期钩子函数,本轮的更新任务就此完成。

到这里,我们也就知道了为啥要将操作domnextTick放在数据修改之后?

vm.message = 'new message' // 更改数据
​
Vue.nextTick(function () {
  vm.$el.textContent === 'new message' // true
})

结论: 因为只要我们修改了任何一个数据,就会触发组件的renderWatcher【当然这个响应式数据必须在template模板中被引用过】,flushSchedulerQueue方法会被第一个推入callbacks数组,操作dom的nextTick回调会排在后面,等到操作dom的nextTick回调执行时,flushSchedulerQueue已经执行完成,此时组件内已经最新的dom结构,这时我们才会获取到正确的dom数据。如果我们将操作dom的nextTick回调放在修改数据之前,就将无法获取到所需要的dom数据。

原文链接:https://juejin.cn/post/7221434573829079095 作者:江北__张小凡

(0)
上一篇 2023年4月14日 上午11:04
下一篇 2023年4月14日 上午11:14

相关推荐

发表回复

登录后才能评论