【源码解析】揭秘nextTick的实现原理,其实就是一个异步操作

598 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第8天,点击查看活动详情

前言

大家好,在上一篇文章extend原理分享中,我们对vue2中一个不是很常用的全局API - extend的用法和使用场景做了简单介绍,并从源码层面对它的实现原理进行梳理和分析。接下来的分享中我们将继续学习vue中的另一个API - nextTick,相比extend,nextTick用的稍微会频繁一些。那么nextTick是干嘛的,在什么时候需要使用nextTick,它又是如何实现的?带着这些问题我们继续往下看。

nextTick的用法及使用场景

我们先从一个简单的获取DOM元素的案例入手:

  • 假如在一个child.vue的data属性中有一个存放了用户名单的数组ary,现在想要通过v-for指令将数组中的名字渲染到页面的列表(li)元素中。
  • 然后在页面渲染完成后,做如下3步操作(在mounted钩子函数中):
    • 利用ref获取到ul下所有li子节点,并通过console.log将子节点个数输出
    • 接着调用数组的pop方法将数组的最后一个元素删除
    • 重复第一步,再利用ref获取到ul下所有li子节点,并通过console.log将子节点个数输出
  • 启动程序看看两次输出的结果是否跟我们的心理预期一致
<ul ref="nameList">
    <li v-for="name in ary" :key="name">{{name}}</li>
</ul>
export default{
    data(){
        return{
            ary:["Alvin","Semon","Yannis","lyq"]
        }
    },
    mounted(){
        console.log(`pop前:${this.$refs.nameList.childNodes.length}`)
        this.ary.pop();
        console.log(`pop后:${this.$refs.nameList.childNodes.length}`)
    }
}

caolouyaqian.png 如上图,从结果来看发现输出的结果并不是我们心里预期的结果,第一次输出了4是没问题的,在调用了pop删除数组中的最后一个元素后,页面上渲染出来的结果(3条内容)也是对的,但是第二次的输出就有问题了,按理说删除元素后第二次的输出应该是3才对,然而为什么第二次输出也是4呢?

原因就是:在vue中所有的DOM更新都是异步的,也就是说数据更新后不会立即触发DOM更新,而是要等到所有数据都更新完成后再一次性触发DOM更新,这么做的目的就是为了节省性能,避免不必要的性能浪费

这时就要到我们本次分享的主角 - nextTick闪亮登场了。Vue官方为我们提供的这个全局API - nextTick就是 为了解决这一问题而生的。一句话总结起来就是:

获取更新后的DOM元素

那么知道了nextTick的用途后,我们把上面的代码用nextTick来改造一下再看看能否达到我们的预期

mounted(){
    console.log(`pop前:${this.$refs.nameList.childNodes.length}`)
    this.ary.pop();
    this.$nextTick(()=>{
        console.log(`pop后:${this.$refs.nameList.childNodes.length}`)
    });    
}

caolouyaqian2.png 诶,这时我们再看页面上输出了3条内容,在nextTick中的第二次输出结果也是3,这回跟我们的预期就一样了。 通过上面这个简单的案例我们知道了nextTick的用法及使用场景。简单总结一下就是:

DOM 更新是异步的,数据更新后不会立即触发DOM更新,这时如果想要获取更新后的DOM就需要调用nextTick方法来获取更新后的DOM

源码解析

那么为什么nextTick就能够获取到更新后的DOM,它又是如何做到的呢?下面我们来解读一下nextTick的源码,看看它为什么就能获取到更新后的DOM。

nextTick

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()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

我们先来看下nextTick函数的主体都干了什么,总结起来就是三件事:

  • 向数组callbacks中添加函数(callbacks在文件头部有定义)

    callbacks是在文件头部定义的一个数组,在nextTick函数执行时首先向数组中添加一个函数,而在该函数体中主要又做了两件事:(概括来讲就是让函数执行)

    • 在函数体中首先判断cb是否存在,如果存在则让回调函数cb执行(cb是调用nextTick时传进来的参数),并通过try catch做异常处理
    • 如果cb不存在,再判断_resolve是否存在,如果存在则让_resolve执行(_resolve对应的就是Promise的resolve函数)
  • 将变量pending置为true,并调用timerFunc函数执行

    这里如果pending为false,则将其置为true,并调用timerFunc。关于timerFunc函数我们会在后面进行详细解析,而pending的作用:就是为了保证在同一时刻,任务队列中只能有一个 flushCallbacks 函数

  • 返回一个promise实例

    如果调用nextTick函数时没有传递参数,则直接返回一个promise实例,目的是把nextTick中的代码放在异步微任务中,也能够达到更新后的DOM的目的

flushCallbacks

在上面我们还提到了一个flushCallbacks函数,它又是干什么的呢

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

从代码来看这个函数很简单

  • 首先就是将变量pending重新置为false,因为只有pending为false时才会执行timerFunc函数。
  • 然后将callbacks拷贝一份保存在变量copies中,并将数组callbacks清空
  • 遍历copies,让copies中的函数执行,其实就是让原callbacks中的函数执行(在nextTick中向callbacks数组添加的那些函数)

timerFunc

在前两步我们已经梳理出:在调用nextTick时首先会向callbacks数组中添加一个函数并在函数体中执行回调函数cb,然后再通过flushCallbacks函数让callbacks数组中的所有函数执行(在nextTick那步添加的那些函数)。那么flushCallbacks又是在什么时候执行的呢,我们继续来看剩下的最后一个模块timerFunc也是nextTick的核心模块。

let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} 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
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
  • 首先判断浏览器是否支持Promise,如果支持则创建一个Promise实例,然后在timerFunc函数中将flushCallbacks函数添加到Promise的异步微任务队列中,并将isUsingMicroTask置为true(默认为false),意思就是使用的是异步微任务
  • 如果不支持Promise,再看是否支持MutationObserver,如果支持则使用MutationObserver将flushCallbacks函数添加到异步微任务队列中,目的也是使用异步微任务让flushCallbacks执行
  • 如果以上两种都不支持,再看setImmediate是否支持,如果支持则利用setImmediate将flushCallbacks添加的异步宏任务队列,注意到这时已经是宏任务了
  • 最后的最后如果实在不行就使用异步宏任务setTimeout,总之就是一个目的:让flushCallbacks异步执行

总结

通过本次分享,我们了解了nextTick的基本用法和使用场景,然后又基于其源码分析梳理了nextTick是如何实现获取更新后的DOM的。简单总结起来就是:

先将nextTick中的回调函数添加到异步队列中,然后再通过异步微任务或宏任务让队列中的函数执行,其原则就是:优先使用原生的Promise.thenMutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。总之最终目的就是让nextTick中的函数异步执行,这样就能够获取到更新后的DOM了。

今天的分享就到这里了。喜欢的小伙伴欢迎点赞哦。