对nextTick的简单理解

3,624 阅读5分钟

还记得第一次接触nextTick是在公司做项目时遇到了问题,最后通过nextTick解决的,之后就去学习了它的相关用法和实现,分享学习路上的小知识,希望大家都能有所收获!

Vue.nextTick()用法

用法:Vue.nextTick([callback,context]);

参数说明:

  • {Function} [callback]:回调函数,不传时提供promise调用;
  • {Object} [context]:回调函数执行的上下文环境,不传默认是自动绑定到调用它的实例上;
// 修改数据
vm.msg = 'Hello'
// DOM 还没有更新
Vue.nextTick(function () {
  // DOM 更新了
  ...   //DOM操作
})

// 作为一个 Promise 使用
Vue.nextTick()
  .then(function () {
    // DOM 更新了
  })

Vue.nextTick()是Vue的全局核心API之一,官网描述如下:

在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。

也就是说它会等待DOM更新完成后再去触发回调函数执行,下面结合使用场景来进一步理解。

nextTick使用场景

场景一:如果要在created钩子函数中操作DOM需要放在Vue.nextTick()回调函数中执行;

<template>
  <div>
    <h3 ref="title">Hello World!</h3>
  </div>
</template>

<script>
  export default {
    created() {
      console.log('created');
      console.log(this.$refs.title)
      this.$nextTick(() => {
        console.log('created-nextTick');
        console.log(this.$refs.title)
      })
    },
    mounted() {
      console.log('mounted');
      console.log(this.$refs.title)
      this.$nextTick(() => {
        console.log('mounted-nextTick');
        console.log(this.$refs.title)
      })
    }
  }
</script>

输出结果:

根据输出值的顺序,可以发现在created()钩子函数执行时DOM并没有进行渲染,此时要是操作DOM并没有任何作用,而如果created里使用了nextTick后是可以获取到DOM对象的。由于mounted()钩子函数执行时已经完成了DOM的挂载和渲染,所以此时去操作DOM是没有任何问题的。

场景二:当数据变化后,我们希望拿到修改数据后的DOM结构去做某些操作时也需要放到Vue.nextTick()回调函数中。

<template>
  <div>
    <h3 ref="title">{{msg}}</h3>
    <el-button @click="changeMsg">Click</el-button>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        msg: 'hello'
      }
    },
    methods: {
      changeMsg() {
        this.msg = 'changed';
        console.log(this.msg);               //顺序1:changed
        console.log(this.$refs.title.innerHTML)      //顺序2:hello
        this.$nextTick(() => {
          console.log(this.$refs.title.innerHTML)     //顺序4:changed
        })
        console.log(this.$refs.title.innerHTML)       //顺序3:hello
      }
    }
  }
</script>

通过执行结果我们发现,修改数据后数据值立马修改了,但是所对应的DOM并没有及时更新,nextTick通过一定的策略去更新DOM,接下来我们看看它的实现原理。

实现原理

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

例如,当你设置 vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。

以上是官网关于异步更新队列的描述,完美解答了nextTick的实现机制。为什么DOM更新是一个异步操作呢?它有什么好处?答:提升了渲染效率!!

复习同步异步编程和事件循环,请看这里:juejin.cn/post/685041…

源码浅析

流程大概是这个样子的:nextTick执行传递了一个回调函数,把传进来的函数放到callbacks数组中,这个数组其实就是一个事件池,添加操作完成之后,根据pending变量执行timerfunc函数,并且把pending的值修改为true,这样在多次同步使用nextTick时就不会再去执行timerfunc,timerfunc是一个函数,函数里做了异步操作【就是让flushCallbacks异步执行】,异步执行是为了在多次同步执行nextTick时,先把回调函数放到事件池中,等同步执行完成之后,再去执行异步的flushCallbacks,flushCallbacks这个函数就是把事件池中的所有回调函数挨个执行。

那先来了解下上面提到的3个很重要的变量:

callbacks:相当于一个事件池,存储所有回调函数;

pending:标记当前是否正在执行回调函数;

timerFunc:用来触发执行回调函数;

下面贴出核心代码:

nextTick

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // push进callbacks数组
  callbacks.push(() => {
     cb.call(ctx)
  })
  if (!pending) {
    pending = true
    // 执行timerFunc方法
    timerFunc()
  }
}

timerFunc

let timerFunc
// 判断是否原生支持Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    // 如果原生支持Promise 用Promise执行flushCallbacks
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
// 判断是否原生支持MutationObserver
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  let counter = 1
  // 如果原生支持MutationObserver 用MutationObserver执行flushCallbacks
  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
// 判断是否原生支持setImmediate 
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
  // 如果原生支持setImmediate  用setImmediate执行flushCallbacks
    setImmediate(flushCallbacks)
  }
// 都不支持的情况下使用setTimeout 0
} else {
  timerFunc = () => {
    // 使用setTimeout执行flushCallbacks
    setTimeout(flushCallbacks, 0)
  }
}

// flushCallbacks 最终执行nextTick 方法传进来的回调函数
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

实际上,nextTick(callback)类似于Promise().resolve().then(callback)或者setTimeout(callback,0);所以如果要想在DOM更新后获取DOM信息,就需要在本次异步任务创建之后创建一个异步任务。下面通过使用setTimeout来做个验证:

<template>
  <h3 class="title">{{msg}}</h3>
</template>

<script>
  export default {
    data() {
      return {
        msg: 'before'
      }
    },
    mounted() {
      this.msg = 'changed';
      let box = document.getElementsByClassName('title')[0];
      setTimeout(() => {
        console.log(box.innerHTML)
      })
    }
  }
</script>