【Vue原理】$nextTick源码分析

3,821 阅读8分钟

在做项目的时候,经常会用到nextTick,它是用来操作DOM,简单理解它是放到异步后去执行的。

然而,Vue更新DOM是异步执行的,只要监听到数据变化,Vue开启一个队列,并缓冲在同一件事情循环中发生的所有数据变更。

所以得研究nextTick的执行原理。

此文可收货:

  1. nextTick底层原理
  2. 工作中为什么不直接使用setTimeout,而使用nextTick

看长篇大论视觉疲劳,所以按照小篇幅发布,关于watch、computed、双向绑定、响应式、diff算法、为什么key不能使用index等相关底层原理,最近半个月内逐渐整理出来,哈哈,其实已经整理了一部分,只不过还得梳理得更完美。

最近写的文章收货大家的👍很有成就感,我会逐渐输出更多高质量让大家有收货的文章。

工作中碰到的问题

以下工作中经常碰到的问题,如果不加$nextTick,都可能导致元素获取失败

获取子元素

this.$nextTick(() => {
  this.$refs.search.form.scheduleId = this.scheduleId;
});
默认取前面一页选取的小节数据,每个学生买的课都有小节列表

获取某个dom元素

this.$nextTick(() => {
  document.getElementById(`input${item.id}`).focus();
});

因为这时候dom可能还没渲染出来,获取不到

获取DOM在nextTick中写的原因

先直接上结论:其实是为了提升性能。因为如果在主线程中更新DOM,循环100次就要更新100次DOM;但是如果等事件循环完成之后更新DOM,只需要更新1次。其实这涉及到JS Event Loop机制。可看我的文章多角度理解浏览器工作(从输入url到页面呈现),这里面有关于Event Loop的知识。

根据源码分析,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher (一个属性有一个watcher)被多次触发,只会被推入到队列中一次。这种在缓冲时,会去除重复数据,对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。

  • 也就是说我们在获取DOM,还是调用子组件显示父传过去的数据的时候,Vue并没有马上去更新DOM数据,而是将这个操作放进一个队列中;
  • 如果我们重复执行的话,队列还会进行去重操作;
  • 等待同一事件循环中的所有数据变化完成之后,会将队列中的事件拿出来处理。

所以,不管是获取DOM,还是调用子组件显示父传过去的数据,都得在nextTick中使用,回调函数中会在DOM更新完成后被调用。

nextTick的具体原理是什么呢?下面分析源码

nextTick源码

源码链接:https://github.com/vuejs/vue/blob/dev/src/core/util/next-tick.js


export let isUsingMicroTask = false

const callbacks = []
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]()
  }
}

/*
这里我们使用微任务使用异步延迟包装器。
在2.5中,我们使用了(宏)任务(与微任务结合使用)。
但是,当状态在重新绘制之前被更改时,它会有一些微妙的问题 (例如#6813,out-in transitions)。
此外,在事件处理程序中使用(宏)任务会导致一些奇怪的行为,这是无法规避的(例如#7109、#7153、#7546、#7834、#8109)。
所以我们现在到处都在使用微任务。
这种权衡的一个主要缺点是存在一些场景:
微任务的优先级过高,并在支持的顺序事件两者之间触发(例如#4521、#6690,它们有解决方案)或者甚至是在同一事件(#6566)之间冒泡。
 */

// nextTick中需要执行的函数
let timerFunc

/*
nextTick行为利用了微任务队列,可以通过Promise.then或MutationObserver访问该队列。
MutationObserver获得了更广泛的支持,但是它受到了严重的干扰,此干扰是在ios> = 9.3.3中的UIWebView触发触摸事件处理程序。触发几次后完全停止工作…因此,如果native Promise可用,我们将使用它:
 */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    // 1.

    p.then(flushCallbacks)
    /*在有问题的UIWebViews中,Promise.then不会完全中断,但它会陷入一种奇怪的状态,即回调被推入微任务队列,但队列没有被刷新,直到浏览器需要做一些其他的工作,比如处理一个计时器。因此,我们可以通过添加一个空计时器来“强制”刷新微任务队列。*/
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = trueelse if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x   
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // 2. 

  /*
  在native Promise不可用时使用MutationObserver,
  //例如PhantomJS, iOS7, android4.4
  (#6466 MutationObserver在IE11中是不可靠的)
  
  MutationObserver是用来监听目标DOM结构是否改变
  */
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterDatatrue
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = trueelse if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // 3. 

  // 技术上,setImmediate利用了(宏)任务队列,但它仍然是比setTimeout更好的选择。
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 4. 
  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()
  }
  // nextTick 没有cb,返回new Promise,所以nextTick不传入函数,后面可以连接then
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

代码分析:

  • 以上代码写了为什么不只用Promise.then或MutationObserver,因为涉及到兼容性的问题。

  • 1(then) 和 2(MutationObserver) 是使用微任务,3(setImmediate) 和 4(setTimeout) 是使用宏任务;

  • 可以看出这边代码Vue 在内部对异步队列其实是做了四个判断,对当前环境进行不断的降级处理,尝试使用原生的Promise.then、MutationObserver和setImmediate,上述三个都不支持最后使用setTimeout;

  • 降级处理的目的都是将flushCallbacks函数放入微任务(判断1和判断2)或者宏任务(判断3和判断4),如果是放入微任务,同步代码之后再执行,如果是宏任务,等待下一次事件循环时来执行

  • nextTick 没有cb,返回new Promise,所以nextTick不传入函数,后面可以连接then;

附:MutationObserver是Html5的一个新特性,提供了监视对DOM树所做更改的能力,MutationObserver()创建并返回一个新的 MutationObserver 它会在指定的DOM发生变化时被调用。也就是如果执行了nextTick,代码中新建的textNode会发生改变,如果改变了就执行MutationObserver构造函数中的回调函数。

不管是微任务还是宏任务中执行,发现最终执行的是flushCallbacks函数,为什么把它放在微任务或者宏任务中去执行呢?单拎出这段代码分析。

const callbacks = []
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]()
  }
}

callbacks是nextTick之后写的回调函数,这里把callbacks数组复制一份,然后把callbacks置为空,最后把复制出来的数组中的每个函数依次执行一遍;所以它的作用仅仅是用来执行callbacks中的回调函数。

总结

流程:

  1. 把回调函数放入callbacks等待执行;
  2. 将执行函数放到微任务或者宏任务中;
  3. 事件循环到了微任务或者宏任务,执行函数依次执行callbacks中的回调。

其他:

  1. nextTick是在setTimeout之前执行,浏览器大部分都支持Promise,then,所以nextTick肯定是微任务,而setTimeout是宏任务;
  2. nextTick 没有cb,返回new Promise,所以nextTick不传入函数,后面可以连接then
  3. 如果代码中使用了多个nextTick,按照顺序执行的
  4. 可以看出来nextTick是对setTimeout进行了多种兼容性的处理,宽泛的也可以理解为将回调函数放入setTimeout中执行。但为什么不直接使用setTimeout呢?setTimeout是宏任务,在下一个时间循环中调用。

https://juejin.cn/post/6844904147804749832