HYN慢慢琢磨一些技术。。。
首页/文章列表/【Vue】探索 nextTick/
【Vue】探索 nextTick
2021-05-10 05:01:59 Web 242人阅读

本文基于 VUE 2.6.11 源码。Vue 3.0 的实现略有变化,但思路仍可借鉴2.6。文章末尾会讲一下3.0相关变化。

关于nextTick,我一直有很多疑惑:

  1. 这个函数是怎么做到在dom更新之后执行回调的?

  2. 响应式数据修改中,有一个将所有修改归并,并统一修改的现象,如何实现的?

    有响应式数据a,b
    a = '修改a';
    nextTick(打印a、b对应的dom)
    b = '修改b'
    // 控制台打印出的a、b对应DOM均为修改后的状态,这又是怎么做到的?
    

我们一一解答:

问题1:

这个事说起来挺有意思,和调用顺序有关,如果调用顺序恰当,确实能在dom更新前先执行回调,比如:

data() {
    return {
      content: 'before'
    }
  },
  mounted() {
    this.test()
  },
  methods: {
      test() {
        this.nextTick(() => {
          console.log(this.$refs.box.innerHTML) // 在修改响应式数据前调用
        })
        this.content = 'after'
        this.nextTick(() => {
          console.log(this.$refs.box.innerHTML)
        })
      }
  }
// 打印结果为:
// before
// after

好,现在我们明白了这东西和顺序有关,那就需要某种数据结构来保存调用顺序。

咱们去看源码:https://github.com/vuejs/vue/blob/18660336a05f667927c5ed5117771d13984ff7b0/src/core/util/next-tick.js#L87

按照 ①---> ⑥ ,的顺序阅读注释

let pending = false // 防止timerFunc函数被重复执行
const callbacks = [] // ① 这个是保留nextTick(callback)中回调函数的数组
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)
    }
  })
  if (!pending) {
    pending = true // 因为timerFunc是异步调用,不做控制的话timerFunc函数可能被重复调用,从这里我们也能看出,timerFunc在异步队列中等待时,callbacks数组会不断积累调用nextTick时传入的函数。
    timerFunc() // ③ 这个函数用于触发异步调用,把flushCallbacks函数丢进异步队列,代码看④
  }
   ... // 有一些与本文无关的代码,删除
}

timerFunc = () => { // ④,这个函数负责把flushCallbacks推入异步调用栈,你不用管它是用setTimeout还是promise.then或MutationObserver或setImmediate
    setImmediate(flushCallbacks) // 看下面👇🏻
}

function flushCallbacks () { // ⑤ 把数组callbacks中的函数取出,依次调用
  pending = false // 下面会把callbacks复制一份并清空,因此无需防止重复调用timerFunc
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]() // ⑥ 执行callbacks中保存的函数!
  }
}

好,现在我们知道这个 按调用顺序保存回调函数 是怎么实现的了。

但此时会引出一个新问题,看下面👇🏻👇🏻👇🏻

修改响应式数据,是怎么和nextTick关联的?我明明没调用啊!

这个和响应式原理有关,我们去看看源码:

https://github.com/vuejs/vue/blob/18660336a05f667927c5ed5117771d13984ff7b0/src/core/observer/scheduler.js#L164

我们都知道,vue的响应式原理是类似于发布订阅模式的,每一个响应式数据,都有对应的事件中心,其中会有一堆观察者watcher来注册,等待数据变化时的通知,那么我们模拟一下:

this.content = 'after'赋值操作被setter拦截,并触一次通知,此时watcher.update()被调用。

update函数又调用了另一个函数queueWatcher

queueWatcher执行了这一行代码 nextTick(flushSchedulerQueue)flushSchedulerQueue负责执行收集到的所有watcher。

OK,找到在哪里调用nextTick了!

源码如下:

https://github.com/vuejs/vue/blob/18660336a05f667927c5ed5117771d13984ff7b0/src/core/observer/watcher.js#L165

  /**
   * Subscriber interface.
   * Will be called when a dependency changes.会在依赖变化时被调用
   */
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)// 调用调用调用调用调用调用调用调用调用 
    }
  }

https://github.com/vuejs/vue/blob/18660336a05f667927c5ed5117771d13984ff7b0/src/core/observer/scheduler.js#L164

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher) // ⚠️⚠️⚠️把所有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) // 这就是调用nextTick的地方
    }
  }
}

问题2:响应式数据修改中,有一个将所有修改归并,并统一修改的现象,如何实现的?

修改响应式数据时,可分为两步:

step1、用一个队列收集watcher。

step2、异步调用flushSchedulerQueue函数,清空队列。

step1收集操作依赖于函数queueWatcher,就在上一个问题末尾处👆🏻👆🏻👆🏻👆🏻👆🏻👆🏻👆🏻👆🏻👆🏻,看带⚠️的注释。

step2连续执行依赖于函数提到的flushSchedulerQueue函数,我们看下源码:

https://github.com/vuejs/vue/blob/18660336a05f667927c5ed5117771d13984ff7b0/src/core/observer/scheduler.js#L71

function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id
  queue.sort((a, b) => a.id - b.id)
  // ⚠️一次性执行所有收集到的watcher⚠️
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index] 
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
    ... // 还有一些无关的代码,删除
  }
}

小结

源码分析到此结束,响应式原理的源码不在这里继续深入,再写就偏题了。

有兴趣的可在vue2.6源码中搜索以下内容,搭配相关文章,自行探索:

export function defineReactive // src/core/observer/index.js,用于构件响应式对象

// src/core/observer/index.js
set: function reactiveSetter (newVal) {    
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()   // setter 拦截赋值,触发一次通知
    }

// src/core/observer/dep.js
notify () { 
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update() // 调用update函数,和上文形成闭环
    }
  }

在Vue3.0中,NextTick的实现有变化吗?

有!摘录一段源码。可以看到,使用promise.then()存储&连接各个回调函数,保证调用顺序。或者直接返回一个promise,让使用者利用 async / await 进行控制,这很原生,妙啊~

// https://github.com/vuejs/vue-next/blob/44996d1a0a2de1bc6b3abfac6b2b8b3c969d4e01/packages/runtime-core/src/scheduler.ts#L42
export function nextTick(
  this: ComponentPublicInstance | void,
  fn?: () => void
): Promise<void> {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

关于nextTick是基于宏任务或是微任务

​ 这个是和运行环境相关的。在源码中,具体的实现被封装为函数timerFunc后才使用。所以,代码层面,nextTick函数也不关心timerFunc的实现究竟是微任务还是宏任务。在 Vue 3 中,2.6的实现方式已被放弃,统一使用promise.then(),构件链状结构实现。

感兴趣的可阅读2.6.11源码:https://github.com/vuejs/vue/blob/18660336a05f667927c5ed5117771d13984ff7b0/src/core/util/next-tick.js#L33

前端~
文章目录
问题1:
修改响应式数据,是怎么和nextTick关联的?我明明没调用啊!
问题2:响应式数据修改中,有一个将所有修改归并,并统一修改的现象,如何实现的?
小结
在Vue3.0中,NextTick的实现有变化吗?
关于nextTick是基于宏任务或是微任务