5

vue this.$nextTick核心原理剖析 超详细,认真看你一定看的懂

 3 years ago
source link: https://blog.csdn.net/weixin_42707287/article/details/111931861
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

上篇文章vue生命周期中我们说过一个句话,那就是mounted中并不会保证所有子组件都被挂载完成后再触发,因此当你希望视图完全渲染完成后再做某些事情时,请在mounted中使用$nextTick。那么$nextTick到底是干嘛用的,为什么能解决我们以上的问题。下面我们来好好了解了解$nextTick。但在此之前,如果不懂js事件循环的人,请先去看下什么是js事件循环,因为要理解$nextTick,必须得先理解js事件循环

js事件循环回顾

下面我们先简单得回顾下什么是事件循环(具体理解请看这 js事件循环)
js处理异步主要有微任务(microTask)和 宏任务 (macroTask),而从开始执行一个宏任务–>执行完这个宏任务中所有同步代码—>清空当前微任务队列中所有微任务—> UI渲染 。 这便是完成了一个事件循环(Tick), 然后开始执行下一个宏任务(相当于下一轮循环)。

Vue异步更新

vue实现dom更新是异步完成的,我们可以从下面这个例子中就能看的出

<body>
    <div id="app">
        <p ref="dom">{{message}}</p>
        <button @click="changeValue">改变值</button>
    </div>
</body>
<script>
    new Vue({
        el: '#app',
        data: {
            message: 'hello world'
        },
        methods: {
            changeValue () {
                this.message = 'hello zhangShan'
                console.log(this.$refs.dom.innerText)
            }
        }
    })
</script>

输出值为
hello world

从上图中,我们可以看出,我们改变了message后,立马去输出p标签的text值,发现还是原来的值。这就很明显了,vue的dom更新,并不是同步的。而是异步的,所以在输出时,实际dom还并没有更新。
那么,为什么要设计成异步的,其实很好理解,如果是同步的,当我们频繁的去改变状态值时,是不是会频繁的导致我们的dom更新啊。这很显然是不行的。

<body>
    <div id="app">
        <p ref="dom">{{message}}</p>
        <button @click="changeValue">改变值</button>
    </div>
</body>
<script>
    new Vue({
        el: '#app',
        data: {
            message: 'hello world'
        },
        methods: {
            changeValue () {
                this.message = 'hello zhangShan'
                this.message = 'hello liShi'
                this.message = 'hello wangWu'
                this.message = 'hello chenLiu'
                console.log(this.$refs.dom.innerText)
            }
        }
    })
</script>

像上图这样,如果vue同步更新的话,将会造成4次dom更新。故vue是异步dom更新的,且更新原理如下(借用官网的话):

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

这句话大部分地方其实都很好理解,我也不做过多的说明,我只说明下这句话中(在下一个的事件循环"tick"中,vue刷新队列并执行实际工作),按理的理解,这个下一个事件循环"tick"其实是个泛指,他并不是指下一个事件循环,才去刷新队列。实际刷新队列是有可能在本次事件循环的微任务中刷新的,也可能是在下一个事件循环中刷新的。这取决于代码当前执行的环境,如若当前执行环境支持promise,那么nextTick内部实际会用Promise去执行,那么队列刷新就会在本次事件循环的微任务中去执行。
也就是说,如果当前环境支持promise,那么nextTick内部会使用promise.then去执行,否则,如果支持mutationObserver,那么会用mutationObserver(什么是mutationObserver),不过mutationObserver在vue2.5以后被弃用了。如果这两种都不支持,才会使用setImmediate,MessageChannel(vue2.5以后才有),或者setTimeout(按顺序,支持哪个优先用哪个)。

这也就是vue的降级策略
优先选择微任务microtask(promise和mutationObserver),不支持的情况下,才不得不降级选用宏任务macrotask(setImmediate, MessageChannel, setTimeout)。

那么,为什么优先选择微任务呢
详情请看 js事件循环。看完后就会明白,在微任务中更新队列是会比在宏任务中更新少一次UI渲染的。

下面我们来证实下我们的猜想,请看下面一段代码

<body>
    <div id="app">
        <p ref="dom">{{message}}</p>
    </div>
</body>
<script>
    new Vue({
        el: '#app',
        data: {
            message: 'hello world'
        },
        mounted() {
            // 第一步
            this.message = 'aaa'

            // 第二步
            setTimeout(() => {
                console.log('222')
            })

            // 第三步
            Promise.resolve().then((res) => {
                console.log('333')
            })

            // 第四步
            this.$nextTick(() => {
                console.log('444')
                console.log(this.$refs.dom)
            })

            // 第五步
            Promise.resolve().then((res) => {
                console.log('555')
            })
        }
    })
</script>

在浏览器环境下,输出如下
在这里插入图片描述
首先,从上图中,我们可以看出

  1. 第四步优先第二步输出了 444 和 p标签,从这里我们可以看出,chrome浏览器环境下 nextTick内部是执行了微任务的,所以优先setTimeout输出了。从这点上是可以验证我们上面的说法的(至于其他环境下的,我这里就不测试了)
  2. 但是,我们还有个疑问,同样是微任务,为什么第三步的promise会晚于第四步输出呢。按照我们js事件循环来看,第三步第四步都是微任务的话,第三步肯定会优先第四步输出的,但是我们看到的结果却是第四步优于第三步输出了,这是为什么呢。其实这个跟我们改变数据触发watcher更新的先后有关,我们先看下面一段代码验证一下是不是跟数据改变触发watcher更新的顺序有关,然后我们再来看为什么跟触发watcher更新的顺序有关。
<body>
    <div id="app">
        <p ref="dom">{{message}}</p>
    </div>
</body>
<script>
    new Vue({
        el: '#app',
        data: {
            message: 'hello world'
        },
        mounted() {
            // 第二步
            setTimeout(() => {
                console.log('222')
            })

            // 第三步
            Promise.resolve().then((res) => {
                console.log('333')
            })

            // 第一步
            this.message = 'aaa'

            // 第四步
            this.$nextTick(() => {
                console.log('444')
                console.log(this.$refs.dom)
            })

            // 第五步
            Promise.resolve().then((res) => {
                console.log('555')
            })
        }
    })
</script>

看上面代码,这次我们数据更新放到第三步和第四步直接去执行,再看输出结果
在这里插入图片描述
大家发现没有,这个时候,第三步的微任务是优先执行了的。是不是说明了,nextTick中的callback啥时候执行,取决于数据是在什么时候发生了改变的啊。那么为什么会这样呢。这我们就要从nextTick源码来看看到底是怎么回事了。我们先来看看源码
下面的源码借鉴于nextTick源码实现

首先,我们知道(响应式原理请自行查看MVVM响应式原理或者vue源码解析),当响应式数据发生变化时,是不是会触发它的setter,从而通知Dep去调用相关的watch对象,从而触发watch的update函数进行视图更新。那我们先看看update函数做了啥

update () {
    /* istanbul ignore else */
    if (this.lazy) {
        this.dirty = true
    } else if (this.sync) {
        /*同步则执行run直接渲染视图*/
        this.run()
    } else {
        /*异步推送到观察者队列中,下一个tick时调用。*/
        queueWatcher(this)
    }
}

update中是不是调用了一个queueWatcher方法啊(我们先将update的调用称作第一步,将queueWatcher函数的调用称作第二步,后面用的上),我们再看这个方法做了什么

 /*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/
export function queueWatcher (watcher: Watcher) {
  /*获取watcher的id*/
  const id = watcher.id
  /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      /*如果没有flush掉,直接push到队列中即可*/
      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.
      let i = queue.length - 1
      while (i >= 0 && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(Math.max(i, index) + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

可以看出,queueWatcher方法内部主要做的就是将watcher push到了queue队列当中。
同时当waiting为false时,调用了一次 nextTick方法, 同时传入了一个参数 flushSchedulerQueue,其实这个参数,就是具体的队列更新函数,也就是说更新dom操作就是在这里面做的。而这个waiting状态的作用,很明显是为了保证nextTick(flushSchedulerQueue)只会执行一次。后续再通过this.xxx改变数据,只会加入将相关的watcher加入到队列中,而不会再次执行nextTick(flushSchedulerQueue)。
现在我们将nextTick(flushSchedulerQueue) 称作第三步

function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child)
  // 2. A component's user watchers are run before its render watcher (because
  //    user watchers are created before the render watcher)
  // 3. If a component is destroyed during a parent component's watcher run,
  //    its watchers can be skipped.
  queue.sort((a, b) => a.id - b.id)

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
    // in dev build, check and stop circular updates.
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' + (
            watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`
          ),
          watcher.vm
        )
        break
      }
    }
  }

我们再来看看nextTick内部,做了些啥

/**
 1. Defer a task to execute it asynchronously.
 */
 /*
    延迟一个任务使其异步执行,在下一个tick时执行,一个立即执行函数,返回一个function
    这个函数的作用是在task或者microtask中推入一个timerFunc,在当前调用栈执行完以后以此执行直到执行到timerFunc
    目的是延迟到当前调用栈执行完以后执行
*/
export const nextTick = (function () {
  /*存放异步执行的回调*/
  const callbacks = []
  /*一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送*/
  let pending = false
  /*一个函数指针,指向函数将被推送到任务队列中,等到主线程任务执行完时,任务队列中的timerFunc被调用*/
  let timerFunc

  /*下一个tick时的回调*/
  function nextTickHandler () {
    /*一个标记位,标记等待状态(即函数已经被推入任务队列或者主线程,已经在等待当前栈执行完毕去执行),这样就不需要在push多个回调到callbacks时将timerFunc多次推入任务队列或者主线程*/
    pending = false
    /*执行所有callback*/
    const copies = callbacks.slice(0)
    callbacks.length = 0
    for (let i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }

  // the nextTick behavior leverages the microtask queue, which can be accessed
  // via either native Promise.then or MutationObserver.
  // MutationObserver has wider support, however it is seriously bugged in
  // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
  // completely stops working after triggering a few times... so, if native
  // Promise is available, we will use it:
  /* istanbul ignore if */

  /*
    这里解释一下,一共有Promise、MutationObserver以及setTimeout三种尝试得到timerFunc的方法
    优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法都会在microtask中执行,会比setTimeout更早执行,所以优先使用。
    如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。
    参考:https://www.zhihu.com/question/55364497
  */
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    /*使用Promise*/
    var p = Promise.resolve()
    var logError = err => { console.error(err) }
    timerFunc = () => {
      p.then(nextTickHandler).catch(logError)
      // in problematic UIWebViews, Promise.then doesn't completely break, but
      // it can get stuck in a weird state where callbacks are pushed into the
      // microtask queue but the queue isn't being flushed, until the browser
      // needs to do some other work, e.g. handle a timer. Therefore we can
      // "force" the microtask queue to be flushed by adding an empty timer.
      if (isIOS) setTimeout(noop)
    }
  } else if (typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]'
  )) {
    // use MutationObserver where native Promise is not available,
    // e.g. PhantomJS IE11, iOS7, Android 4.4
    /*新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),即textNode.data = String(counter)时便会触发回调*/
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
      characterData: true
    })
    timerFunc = () => {
      counter = (counter + 1) % 2
      textNode.data = String(counter)
    }
  } else {
    // fallback to setTimeout
    /* istanbul ignore next */
    /*使用setTimeout将回调推入任务队列尾部*/
    timerFunc = () => {
      setTimeout(nextTickHandler, 0)
    }
  }

  /*
    推送到队列中下一个tick时执行
    cb 回调函数
    ctx 上下文
  */
  return function queueNextTick (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()
    }
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        _resolve = resolve
      })
    }
  }
})()

在这个函数内,我们可以看到

  1. 首先可以看出,nextTick是一个立即执行函数,也就是说这个函数在定义的时候就已经自动执行一次了,而自动执行时,return function queueNextTick前面的代码是不是就已经执行了啊。这也是nextTick第一次执行
  2. 定义了一个函数timerFunc,这是个关键函数,因为这个函数是怎样的,决定了我们的nextTick内部最终是执行了微任务,还是执行了宏任务。(定义nextTick函数时就定义了)
  3. 定义了一个nextTickHandler函数,这个函数作用很明显,就是执行我们调用nextTick时,所传进来的callback回调函数,也就是说当我们执行this.$nextTick(()=> {})时,内部传递进来的这个函数,就是在nextTickHandler内被执行的。(定义nextTick函数时就定义了))
  4. return了一个函数queueNextTick,所以我们可以看出,当我们平常调用this.$nextTick(cb)时以及上面调用nextTick(flushSchedulerQueue),实际上,是不是调用了这个queueNextTick啊, 此时,我们将queueNextTick称为第四步。

这个时候,我们继续看queueNextTick,这里做了什么啊

  1. 将传入进来的callback回调函数,push到了callbacks数组中,为后面nextTickHandler函数执行callback做准备
  2. 当pending为false时,调用了timerFunc函数,此时我们将timerFunc函数的执行,称为第五步

大家发现没有,这个pending其实就是解开我们问题的关键啊,为什么这么说呢。我们先看timerFunc内做了啥,再回过头来解释

那么timerFunc做啥了

if (typeof Promise !== 'undefined' && isNative(Promise)) {
    /*使用Promise*/
    var p = Promise.resolve()
    var logError = err => { console.error(err) }
    timerFunc = () => {
      p.then(nextTickHandler).catch(logError)
      // in problematic UIWebViews, Promise.then doesn't completely break, but
      // it can get stuck in a weird state where callbacks are pushed into the
      // microtask queue but the queue isn't being flushed, until the browser
      // needs to do some other work, e.g. handle a timer. Therefore we can
      // "force" the microtask queue to be flushed by adding an empty timer.
      if (isIOS) setTimeout(noop)
    }
  } else if (typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]'
  )) {
    // use MutationObserver where native Promise is not available,
    // e.g. PhantomJS IE11, iOS7, Android 4.4
    /*新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),即textNode.data = String(counter)时便会触发回调*/
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
      characterData: true
    })
    timerFunc = () => {
      counter = (counter + 1) % 2
      textNode.data = String(counter)
    }
  } else {
    // fallback to setTimeout
    /* istanbul ignore next */
    /*使用setTimeout将回调推入任务队列尾部*/
    timerFunc = () => {
      setTimeout(nextTickHandler, 0)
    }
  }

可以看出,timerFunc内部定义了一些异步函数,视当前执行环境的不同,timerFunc内部执行的异步函数不同,他内部可能是promise, 可能是mutationObserver, 可能是setTimeout。(我们当前例子是在chrome浏览器下,timerFunc内部是Promise无疑)。但可以看出,不管内部是什么异步函数,它都在异步的回调中执行了nextTickHandler,而nextTickHandler是决定我们调用this.$nextTick(() => {})时,内部回调函数啥时候执行的关键。故可以得出结论,timerFunc内部的异步函数的回调啥时候执行,我们this.$nextTick()内的回调就啥时候执行

好,到了这一步,我们就可以来重新梳理下,代码是怎么走的啦。

mounted() {
     // 第一步
     this.message = 'aaa'

     // 第二步
     setTimeout(() => {
         console.log('222')
     })

     // 第三步
     Promise.resolve().then((res) => {
         console.log('333')
     })

     // 第四步
     this.$nextTick(() => {
         console.log('444')
         console.log(this.$refs.dom)
     })

     // 第五步
     Promise.resolve().then((res) => {
         console.log('555')
     })
 }
  1. this.message = ‘aaa’ 执行,响应式数据发生变化,是不是会触发setter, 从而进一步触发watcher的update方法,也就是我们前面说的第一步
  2. update方法内执行了queueWatcher函数(也就是我们上面说的第二步),将相关watcher push到queue队列中。并执行了nextTick(flushSchedulerQueue) ,也就是我们上面说的第三步。此时,记住了,我们这里是第一次执行了nextTick方法。此时,我们代码中的this.$nextTick()还并没有执行,只执行了this.message = ‘aaa’ , 但是vue内部自动执行了一次nextTick方法,并将flushSchedulerQueue当作参数传入了
  3. nexTick内部代码执行,实际上是执行了queueNextTick,传入了一个flushSchedulerQueue函数,将这个函数加入到了callbacks数组中,此时数组中只有一个cb函数flushSchedulerQueue。
  4. pending状态初始为false,故执行了timerFunc,
    在这里插入图片描述
  5. timerFunc一旦执行,发现内部是一个promise异步回调,是不是就加入到微任务队列了,此时,是不是微任务队列中的第一个任务啊。但注意,此时,callbacks内的回调函数还并没有执行,是不是要等这个微任务执行的时候,callbcaks内的回调函数才会执行啊
  6. 此时,跳出源码,继续向下执行我们写的代码
mounted() {
            // 第一步
            this.message = 'aaa'

            // 第二步
            setTimeout(() => {
                console.log('222')
            })

            // 第三步
            Promise.resolve().then((res) => {
                console.log('333')
            })

            // 第四步
            this.$nextTick(() => {
                console.log('444')
                console.log(this.$refs.dom)
            })

            // 第五步
            Promise.resolve().then((res) => {
                console.log('555')
            })
        }
  1. 碰到setTimeout,加入宏任务队列,
  2. 碰到第一个Promise(console.log(333)的这个), 加入微任务队列,此时微任务队列中,是不是就有两个微任务啦。我们现在加入的这个是第二个
  3. 此时this.$nextTick()函数执行,相当于就是调用了queueNextTick,并传入了一个回调函数。此时注意了
    在这里插入图片描述
    之前,我们是不是执行过一次queueNextTick啊,那么pending状态是不是变为true了,那么timerFunc是不是这个时候不会再执行了,而此时唯一做的操作就是将传入的回调函数加入到了callbacks数组当中。
    所以,实际timerFunc这个函数的执行,是在this.message = ‘aaa’ 执行的时候调用的,也就意味着,timerFunc内的异步回调, 是在 this.message = ‘aaa’ 时被加入到了微任务队列当中,而不是this.$nextTick()执行时被加入到微任务队列的。所以这也就是前面我们为什么说pending状态是解决问题的关键,因为他决定了,异步回调啥时候加入到微任务队列
    而this.$nextTick(cb)执行时,唯一的作用就是将cb回调函数加入到了callbacks数组当中,那么在微任务队列被执行的时候,去调用callbacks中的回调函数时,是不是就会调用到我们现在加入的这个回调函数啊
  4. 继续,碰到第二个promise(console.log(555)的这个),又加入到微任务队列中。
  5. 此时,微任务队列中存在3个任务,第一个是timerFunc中的promise回调,第二个是console.log(333)的那个promise回调,第三个是console.log(555)的那个promise回调。
  6. 故,同步代码执行完成后,优先清空微任务队列,那么是不是先执行了第一个微任务啊,也就是timeFunc内的那个微任务
    在这里插入图片描述
    而这个微任务一执行,是不是调用了nextTickHandler, nextTickHandler是不是就依次执行了callbacks中的回调函数啊,此时callbacks中有两个回调函数,第一个就是flushSchedulerQueue,用于更新dom,第二个就是我们传进来的这个
    在这里插入图片描述
    所以,我们第二个回调函数执行时,dom是不是已经更新了啊。然后才输出 444 和 p标签
  7. 然后再取出第二个微任务去执行,就输出了333
  8. 再取出第三个微任务去执行,就输出了555
  9. 再之后,微任务队列清空,开始下一轮循环,取出宏任务队列中的setTimeout的回调并执行,输出222。

这也就是所有的一个执行过程了,讲的应该都很明了了。

整体逻辑可能会有点绕,但是认真看,相信你一定能够看的懂得。看不懂,请私信我,或者直接评论,我会一一解答得。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK