Vue nextTick 作用及源码实现

145 阅读4分钟

写在开头

本文只是对 nextTick 的实现进行部分解析,若有错误,欢迎指正

nextTick 的原理是什么

想要理解原理,首先我们要知道浏览器的事件循环机制,这在上文也提到过,关于事件循环可以看我之前写的文章

浏览器和EventLoop机制

为什么优先微任务而不是宏任务

当你了解事件循环之后,我们再来讲讲 nextTick 是如何实现的(我这里说的是 2.6 版本),对于 nextTick 首先考虑的是用微任务去实现,为什么不优先考虑使用宏任务,首先看下官方在源码中的注释

image.png

意思是说,使用宏任务可能在状态发生变化时有一些微妙的问题,这是第一点

其次,如果是使用宏任务去实现,我们知道宏任务是在下一次事件循环开始时执行,那么因为 DOM 发生更新了,所以浏览器势必会进行重新渲染,若使用微任务实现,微任务的执行是在渲染前的,这就会导致两者执行时间上会有差距,微任务明显会更快,因为没有渲染嘛,而且如果你了解浏览器内核的话,你应该知道 js引擎 和 GUI 渲染线程是互斥的,一方执行就会阻塞另一方,在不同线程之间进行切换也必定会导致性能上的损耗,所以优先使用微任务

微任务及宏任务的源码实现

对于微任务

  • 首先判断能否使用 Promise
  • 若无法使用 Promise 则使用 MutationObserver(监视 DOM 更改,在 DOM 更改时被调用) 若微任务无法使用,再使用宏任务
  • 首先判断能否使用 setImmediate
  • 最后考虑使用 setTimeout
// 以下是2.6版本中 nextTick 的部分源码,不长,请耐心阅读

// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // In problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Technically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

nextTick 的作用是什么

要想回答这个问题,我认为得从两个方面来说

Vue 异步更新 DOM 并且只更新一次

对于 Vue 来说,它的 DOM 更新是异步的,也就是说并不是一个数据发生改变后,相应的 DOM 就会立马发生改变,实际上 Vue 会缓存一次事件循环中的多个数据修改,并统一执行一次更新,那么具体是如何实现的呢?

首先,在 Vue 里会创建一个队列,这个队列里保存着需要进行更新的 Watcher(若对 Watcher 不了解,可以去看下 Vue 的响应式原理,我也会在下面推荐一些文章),在这里需要注意的是如果一个 Watcher 被多次触发,那么实际上只会向队列推入一次,这样也能减少不必要的计算和对 DOM 的操作。 然后 Vue 会在 nextTick 函数中对这些 watcher 所对应的 DOM 进行相应的更新。

具体实现如下

注:以下代码都只保留最关键部分

  1. 当一个属性发生变化之后,触发 setter 方法,在该方法中会去通过 dep.notify 通知属性的依赖列表进行相应的更新
const defineReactive = (data, key, value) => {
  let dep = new Dep(); // 对象里的每一个属性都有自己的依赖,通过该实例对象去管理依赖
  Object.defineProperty(data, key, {
    set(newVal) {
      if (newVal === value) {
        return;
      }
      value = newVal;
      dep.notify(); // 触发依赖
    }
  });
};
  1. dep.notify 内部会通知每个依赖执行 update 方法进行更新
class Dep { // 把依赖抽象成一个类,使用这个类可以帮助我们管理依赖
  notify() { // 通知列表中的依赖进行更新
    const subs = this.subs.slice()
    for (let i = 0; i < subs.length; i++) {
      subs[i].update() // update 是 watcher 提供的方法,可以进行dom的更新或者执行一些其他的操作
    }
  }
}
  1. update 方法,默认情况下会将 watcher 放入队列中
// watcher.js
// 当依赖发生变化时,触发更新
update() {
  if (this.lazy) {
    // 懒执行会走这里, 比如computed
    this.dirty = true
  } else if (this.sync) {
    // 同步执行会走这里,比如this.$watch() 或watch选项,传递一个sync配置{sync: true}
    this.run()
  } else {
    // 将当前watcher放入watcher队列, 一般都是走这里
    queueWatcher(this)
  }
}
  1. queueWatcher 方法,在放入 wacher 之前会先判断队列中是否已经存在,不存在才会被推入队列,这也是为什么在一次事件循环中更新一个数据多次最终只会触发一次更新的原因。
// scheduler.js
/*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/
export function queueWatcher (watcher: Watcher) {
  /*获取watcher的id*/
  const id = watcher.id
  /*检验id是否存在,已经存在则直接跳过,不存在则标记在has中,用于下次检验*/
  if (has[id] == null) {
    has[id] = true
    // 如果flushing为false, 表示当前watcher队列没有在被刷新,则watcher直接进入队列
    if (!flushing) {
      queue.push(watcher)
    } else {
      // 如果watcher队列已经在被刷新了,这时候想要插入新的watcher就需要特殊处理
      // 保证新入队的watcher刷新仍然是有序的
      let i = queue.length - 1
      while (i >= 0 && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(Math.max(i, index) + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      // wating为false,表示当前浏览器的异步任务队列中没有flushSchedulerQueue函数
      waiting = true
      // 需要注意这里推入的是一个函数
      nextTick(flushSchedulerQueue)
    }
  }
}
  1. nextTick 中实现异步更新,因为是异步更新,这就是为什么数据发生修改后,dom 没有立刻发生变化的原因
/* globals MutationObserver */
const callbacks: Array<Function> = []
let pending = false

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

/**
 * @internal
 */
export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e: any) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc() // timerFunc 方法内部实现就是通过微任务或者宏任务去执行 flushCallbacks 方法清空队列
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

对 DOM 进行操作

有些时候,我们在修改数据后,可能需要对相应的 DOM 进行操作,但如果你直接在数据修改后就访问相应的 DOM,你可能会发现 DOM 并没有发生改变,那这个时候就需要使用 nextTick 函数,该函数会在 DOM 更新后立马执行,这是第二个作用

文章参考及推荐

nextTick 源码

你真的理解$nextTick么

「Vue系列」之面试官问NextTick是想考察什么?

Vue响应式原理-理解Observer、Dep、Watcher

面试官,你再试试问Vue响应式原理?(标题党)

Vue异步更新机制以及$nextTick原理