这里有一篇关于nextTick你所要知道的东西~

2,045 阅读6分钟

开头

我自己本身的技术栈是Vue,之前也多多少少在看了一些关于Vue的源码层面的东西。虽然进了公司以后用的是React,但是我觉得学什么技术栈并不是最重要的,最重要的是我们在学习的过程中去弄懂它的原理,我们看源码也不是为了看而去看,更重要的是去学习人家的设计思想和设计理念以及编码风格,我们每个人一开始不都是高手,在成长的路程中,我们都是通过学习别人和模仿别人得到不断的成长,就像你在小时候学习说话和走路一样,遇到困难的我们也不要去畏惧,不要听到源码就畏缩,勇敢面对就好了,这迟早也是我们学习的过程中要面对的一座大山。这篇文章其实之前已经写了一半了,然后今天趁着有空就把它给继续完善了,以后可能自己也主要转React技术栈了,关于React的一些原理性的东西以后再好好研究。也希望大家看完这篇文章能有所学习~

事件循环机制

在浏览器环境中,我们可以将我们的执行任务分为宏任务和微任务

  • 宏任务: 包括整体代码scriptsetTimeoutsetIntervalsetImmediate、 I/O 操作、UI 渲染 等
  • 微任务: Promise.thenMuationObserver 特别说明的是new Promise里面的内容是同步执行的,像new Promise(resolve(console.log('1')))是同步执行的,resolve之后.then进入微任务队列,具体的内容请往下继续看。

在浏览器环境中: 事件循环的顺序,决定js代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。大概就是先执行同步代码,然后就将宏任务放进宏任务队列,宏任务队列中有微任务就将其放进微任务队列,当宏任务队列执行完就检查微任务队列,微任务队列为空了就开始下一轮宏任务的执行,往复循环。 宏任务 -> 微任务 -> 宏任务 -> 微任务一直循环。

我们下面看个图有助于我们理解,另外如果要看事件循环相关的题目和讲解,可以参考我这篇文章里的事件循环小节👉前端基础知识大汇总(欢迎收藏)

当我们理解了基本的事件循环机制之后,我们就可以开始看看Vue里面的的nextTick是怎么运作的了。

nextTick原理

Vue是异步执行DOM更新的,一旦观察到数据变化,Vue就会开启一个任务队列,然后把在同一个事件循环 (Event loop) 中观察到数据变化的 Watcher(Vue源码中的Wacher类是用来更新Dep类收集到的依赖的)推送进这个队列。如果这个watcher被触发多次,只会被推送到队列一次。这种缓冲行为可以有效的去掉重复数据造成的不必要的计算和DOM操作。而在下一个事件循环时,Vue会清空队列,并进行必要的DOM更新。这部分的详情可以参考Vue响应式实现的源码。

nextTick的作用是为了在数据变化之后等待 Vue 完成更新 DOM ,可以在数据变化之后立即使用 Vue.nextTick(callback),JS是单线程的,拥有事件循环机制,nextTick的实现就是利用了事件循环的宏任务和微任务。

当我们调用nextTick的时候,传入一个回调函数,这时候会发生以下的步骤:

  • 将回调函数传入一个callbacks数组中

  • 判断采用哪种异步回调方式

    • 首先尝试使用Promise.then(微任务)
    • 尝试使用MuationObserver(微任务)回调
    • 尝试使用 setImmediate(宏任务)回调
    • 最后尝试使用setTimeout(宏任务)回调
  • 到最后执行 flushCallbacks() 方法,遍历callbacks数组,依次执行里边的每个函数

我们再看一眼源码,源码在(Vue2.6.12版本) src -> core -> util -> next-tick.js

//判断宏任务和微任务的变量
//true为正在使用微任务
export let isUsingMicroTask = false

//存放回调函数的数组
const callbacks = []
//该变量用来设置异步锁
let pending = false

//用来遍历执行回调函数数组里的函数
function flushCallbacks () {
  //执行回调函数时将异步锁给重置
  pending = false
  //防止出现nextTick中包含nextTick时出现问题,浅拷贝callbacks数组之后将其清空
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  //判断是否支持Promise
  const p = Promise.resolve()
  timerFunc = () => {
    //执行环境支持Promise则使用Promise.then(微任务)去执行回调函数
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  //设置为true,正在使用微任务
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
   //当执行环境不支持Promise的时候,我们就做此处的判断
   //如果执行环境不是IE浏览器以及支持MutationObserver这个API的话就执行这里
   //比如在IOS7,Android 4.4的环境下 (IE11中的MutationObserver是不可靠的)
  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)) {
  // 如果Promise和MutationObserver都不支持那就使用setImmediate
  // 该方法优先级依然比setTimeout高,setImmediate属于宏任务
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  //如果上面三种方法都不支持才使用setTimeout,setTimeout也属于宏任务
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

//传入一个回调函数和一个上下文对象
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()
  }
    
  //当我们不传回调函数的时候,提供一个Promise化的调用
  //相当于可以nextTick().then(() => {})
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

我们再来看一个在Vue中的使用例子:

Vue生命周期的created钩子函数进行DOM操作的话我们就需要使用到nextTick了。我们将DOM的操作放在Vue.nextTick()的回调函数中,原因是在created钩子函数执行的时候DOM 其实并未进行挂载和渲染,此时就是无法操作DOM的,我们将操作DOM的代码中放到nextTick中,等待下一轮事件循环开始,DOM就已经进行挂载好了,而与这个操作对应的就是mounted钩子函数,因为在mounted执行的时候所有的DOM挂载已完成。

  created(){
    vm.$nextTick(() => {  
        //不使用this.$nextTick()方法操作DOM会报错
        this.$refs.test.innerHTML="created中操作了DOM"
    });
  },

另外,当我们修改了data里的数据时,并不能通过操作DOM去获取到里面的值,此时我们也需要用到nextTick

<template>
  <div class="test">
    <p id="msg">{{msg}}</p>
  </div>
</template>
 
<script>
export default {
  name: 'Test',
  data () {
    return {
      msg:"你好,Vue~",
    }
  },
  methods: {
    changeMsg() {
      this.msg = "你好,王大锤!"  //vue数据改变,改变了DOM里的innerText
      let msgEle = document.getElementById('msg').innerText  //后续js对dom的操作
      console.log(msgEle)  // 输出可以看到data里的数据修改后DOM并没有立即更新,后续的DOM不是最新的
    },
  }
}
</script>

我们使用nextTick对上面的代码进行一下改善就能看到正确的操作结果啦

  methods: {
    changeMsg() {
      this.msg = "你好,王大锤!"  //vue数据改变,改变了DOM里的innerText
      this.$nextTick(() => {
         let msgEle = document.getElementById('msg').innerText  
         console.log(msgEle)  // 输出可以看到data里的数据修改后DOM已经更新完成
      })
    },
  }

总结

文章就暂时先写到这了,其实很多时候我们学习一个东西,首先还是得学会如何去使用,等到用到一定程度的时候,就可以去学习它的设计原理了,因为我们学习一个东西不能总是浮于表层,要发展成一个T型人才,就得在某个领域里去纵向发展,然后其它领域里横向扩展,这样我们才能永远保持学习的热枕,对这个世界保持一种探索的欲望,永远不要满足于现状。