vue的$nextTick实现原理及源码分析

在使用vue的过程中, 我们难免会遇到这样的问题:
操作: 更改数据 –> 获取dom, 获得的dom却没有反应更改的数据, 比如下面的代码:

  <template>
    <div>
      <span ref="dom">{{ message }}</span>
    </div>
  </template>
  data() {
    return {
      message: "hello world"
    }
  },
  mounted() {
    this.message = "hi vue";
    console.log(this.$refs.dom.innerHTML)
  },

此时打印的不是hi vue, 而是hello world. 看了下vue的官方文档, 发现Vue 在更新 DOM 时是异步执行的, 要想获得数据变化导致更新后的dom, 可以用vue提供的api: $nextTick.

  <template>
    <div>
      <span ref="dom">{{ message }}</span>
    </div>
  </template>
  data() {
    return {
      message: "hello world"
    }
  },
  mounted() {
    this.message = "hi vue";
    this.$nextTick(()=> {
      console.log(this.$refs.dom.innerHTML)
    })
  },

此时打印出来的就是我们想要的hi vue.
那么$nextTick是怎么实现的呢, 接下来我们就一探究竟. 首先需要了解一下浏览器的工作原理.

js运行机制

我们都知道, 就是是单线程, 基于事件循环的. 事件循环大致分为以下步骤:

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
  3. 一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。

主线程(process event)执行的过程就是一个tick, 执行过程中遇到的异步task会放在“任务队列” (event queue)来调度. 规范中规定task分成两大类: macro task(宏任务) 和 micro task(微任务). 执行循序如下:

常见的 macro task 有 主线程代码块、setTimeout/setInterval、MessageChannel、postMessage、setImmediate、UI rendering;常见的 micro task 有 MutationObsever 和 Promise.then、process.nextTick等

$nextTick的作用

我们都知道vue的响应式原理, 当数据变化–> 触发setter –>通知订阅了该数据的watcher –> 重新渲染组件.

这个派发更新的过程, 会触发watcher的update函数, 然后调用queueWatcher.

/**
 * Subscriber interface.
 * Will be called when a dependency changes.
 */
Watcher.prototype.update = function update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this);
  }
};

queueWatcher引入了一个队列的概念, 把这些 watcher 先添加到一个队列里,然后在 nextTick 后执行 flushSchedulerQueue渲染更新组件.

/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
function queueWatcher (watcher) {
  var id = watcher.id;
  if (has[id] == null) {
    has[id] = true;
    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.
      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;

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue();
        return
      }
      nextTick(flushSchedulerQueue);
    }
  }
}

$nextTick如何在vue中发挥作用呢? vue的文档如是说
可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

$nextTick源码分析

nextTick函数的实现是单独放在源码中的src/core/util/next-tick.js文件下的.

首先定义了一些变量公用的变量, 因为一个时间段内只有一个tick在执行

export let isUsingMicroTask = false // 是否使用micro task
const callbacks = [] // 缓存函数的数组
let pending = false // 用来标志是否正在等待执行回调函数

然后定义了一个函数flushCallbacks.

function flushCallbacks () {
  pending = false
  //  拷贝出函数数组副本
  const copies = callbacks.slice(0)
  //  把函数数组清空
  callbacks.length = 0
  // 依次执行函数
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

我们多次调用$nextTick时, 就会把传入的回调函数cb压入 callbacks函数数组, 最后在下一个tick的时候, 一次性地调用flushCallbacks. flushCallbacks 的逻辑非常简单,对 callbacks 遍历,然后执行相应的回调函数。
next-tick.js源码的最后定义了nextTick函数, nextTick函数实际调用了timerFunc函数, 而timerFunc函数会更加浏览器的兼容情况调用flushCallbacks函数.

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 把传入的回调函数cb压入 callbacks函数数组
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc() // 把flushCallbacks放在下一个tick的函数
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

大体的流程梳理好了, 下面我们来看下timerFunc()是如何实现的, 这里依次优雅降序的使用js的方法.

1.promise.then延迟调用
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
}

如果浏览器支持Promise,那么就用Promise.then的方式来延迟函数调用, 因为Promise是属于micro task, 所以Promise.then方法可以将函数延迟到当前函数调用栈最末端. 最后把全局变量isUsingMicroTask 设为true.

2. MutationObserver 监听变化
else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  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
}

MutationObserver是h5新加的一个功能, 其功能是监听dom节点的变动,在所有dom变动完成后,执行回调函数。
MutationObserver可以监听以下变动:

  • childList:子元素的变动
  • attributes:属性的变动
  • characterData:节点内容或节点文本的变动
  • subtree:所有下属节点(包括子节点和子节点的子节点)的变动

可以看出, 源码中创建了一个文本节点, 通过改变文本节点的内容来触发变动, MutationObserver监听到变动, 就会自动把回调函数放在当前函数调用栈最末端, 以此达到延迟的效果.MutationObserver也属于micro task, 因此把isUsingMicroTask 设为true.

3. setImmediate延迟器
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Techinically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }

如果浏览器不支持原生的promise和MutationObserver, 便使用setImmediate, 源码解释使用setImmediate实际上是一个macro task, 所以会把回调放进下一个tick, 但也比setTimeout优胜一筹.

4.setTimeout延迟器

 else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

最后当以上的方法都不支持, 会使用最后的兼容方法setTimeout来实现延迟, setTimeout也属于macro task, 所以因此把isUsingMicroTask保持为false.

Microtask 的应用–引用顾轶灵的回答
为啥要用 microtask?根据HTML Standard,在每个 task 运行完以后,UI 都会重渲染,那么在 microtask 中就完成数据更新,当前 task 结束就可以得到最新的 UI 了。反之如果新建一个 task 来做数据更新,那么渲染就会进行两次。

总结

Vue.js 提供了 2 种调用 nextTick 的方式,一种是全局 API Vue.nextTick,一种是实例上的方法 vm.$nextTick,无论我们使用哪一种,最后都是调用 next-tick.js 中实现的 nextTick 方法。

最后再看这个栗子:

<template>
  <div>
    <span ref="dom">{{ message }}</span>
    <button @click="change">change</button>
  </div>
</template>
  data() {
    return {
      message: "hello world"
    }
  },
  methods: {
    change() {
      this.$nextTick(()=> {
        console.log(this.$refs.dom.innerHTML)
      })
      this.message = "hi vue";
      this.$nextTick(()=> {
        console.log(this.$refs.dom.innerHTML)
      })
    }

大家猜猜点击button之后打印出来的是什么呢?

在不了解$nextTick的原理时, 我以为答应的是两个hi vue, 因为$nextTick是延时执行的. 事实是第一个打印的是hello world, 第二个打印的是hi vue. 为什么呢? 其实. 当给this.message赋值的时候, 会被watcher监听到, 导致dom的更新. 然而这个dom的更新不会立即触发, 而是会调用nextTick函数来延迟dom的更新(官方文档解释是为了缓冲在同一事件循环中发生的所有数据变更). 所以在上面的d代码中, 我们第一次调用this.$nextTick, 会先把回调放进上面源码所说的函数队列callbacks里面, 执行this.message = “hi vue” 又会调用nextTick, 把dom的更新放在callbacks里, 所以第一个this.$nextTick的回调是在dom更新前执行, 而第二个this.$nextTick的回调是在dom更新后执行的, 打印出来的结果便是hello world hi vue.

所以! 如果大家想要在修改某个数据后拿到相应的dom, 一定要记得把$nextTick的逻辑放在数据修改之后, 这样才能成功拿到dom更新后的结果哦!

本人能力有限, 有不当之处欢迎指正, 谢谢观看~完

参考
1. https://cn.vuejs.org/v2/guide/reactivity.html#%E5%BC%82%E6%AD%A5%E6%9B%B4%E6%96%B0%E9%98%9F%E5%88%97
2. https://ustbhuangyi.github.io/vue-analysis/reactive/next-tick.html#js-%E8%BF%90%E8%A1%8C%E6%9C%BA%E5%88%B6
3. http://www.ruanyifeng.com/blog/2014/10/event-loop.html

发表评论