浅析Vue.nextTick

662 阅读7分钟

浅析Vue.nextTick

什么是Vue.nextTick?

  Vue实现响应式并不是当数据发生变化,DOM就立刻变化,而是创建一个队列,缓冲在同一时间循环中发生的所有数据改变。就如一个外卖员接订单,在不同的店家,接到了送往同一个地方的单子,并不是接一个就送一个,而是一个个拿完之后先放入外卖箱,再一起配送,避免多次配送。如果同一个watcher被多次触发,只会依次推入到队列中。依靠这种缓冲去除重复数据引起的不必要计算及DOM操作,Vue.nextTick就是在DOM更新后自动执行该回调函数。

应用场景

  1. 在Vue的生命周期created钩子函数进行DOM操作,我们知道created时DOM并未生成,此时DOM操作一定要放入Vue.nextTick函数中。

  2. 数据变化后,当需要操作DOM时,操作放入Vue.nextTick回调中

事件循环及UI Render

    首先我们应先了解事件循环,一次macrotasks + microtasks 称为一次ticket,ticket结束之后会触发浏览器的重绘操作(不一定每次都执行),换言之,执行任务的耗时会影响视图渲染的时机,通常浏览器以每秒60帧(60fps)的速率刷新页面,据说这个帧率最适合人眼交互,大概16.7ms渲染一帧,所以如果要让用户觉得顺畅,单个macrotask及它相关的所有microtask最好能在16.7ms内完成。但也不是每轮事件循环都会执行视图更新,浏览器有自己的优化策略,例如把几次的视图更新累积到一起重绘,重绘之前会通知requestAnimationFrame执行回调函数,也就是说requestAnimationFrame回调的执行时机是在一次或多次事件循环的UI render阶段。

<body>
    <button onclick="change()">change</button>
    <div id="xml">1</div>
  </body>
  <script>
    window.onload = () => {
      function test2() {
        setTimeout(() => {
          document.getElementById("xml").style.color = "red"
          document.getElementById("xml").innerText = "red"
        })
      }
      function test3() {
        Promise.resolve().then(()=>{
          document.getElementById("xml").style.color = "red"
          document.getElementById("xml").innerText = "red"
        })
      }
      function test4() {
        const time = new Date().getTime();
        while(true) {
          if(new Date().getTime() - time > 20) {
            break
          }
        }
      }
      function test1() {
        document.getElementById("xml").style.color = "blue"
        document.getElementById("xml").innerText = "blue"
        test4()
        test3()
        // test2()
      }

      setTimeout(test1)
    }
    
  </script>

我们从performance面板来解读上述代码渲染机制,首先我们了解下Timings:

(1)DCL(DOMContentLoaded)表示 HTML 文档加载完成事件。当初始 HTML 文档完全加载并解析之后触发,无需等待样式、图片、子 frame 结束。作为明显的对比,load 事件是当个页面完全被加载时才触发
(2)FP(First Paint)首屏绘制,页面刚开始渲染的时间。
(3)FCP(First Contentful Paint)首屏内容绘制,首次绘制任何文本,图像,非空白canvas 或 SVG 的时间点。
(4)FMP(First Meaningful Paint)首屏有意义的内容绘制,这个“有意义”没有权威的规定,本质上是通过一种算法来猜测某个时间点可能是 FMP。有的理解为是最大元素绘制的时间,即同LCP(Largest Contentful Paint )。其中 FP、FCP、FMP 是同一条虚线,三者时间不一致。比如首次渲染过后,有可能出现 JS 阻塞,这种情况下 FCP 就会大于 FP。
(5)L(Onload)页面所有资源加载完成事件。
(6)LCP(Largest Contentful Paint )最大内容绘制,页面上尺寸最大的元素绘制时间

各种颜色柱子代表的事件分类: image.png Loading 事件

内容说明
Parse HTML浏览器解析HTML
Finish Loading网络请求完成
Receive Data请求的响应数据到达事件,如果响应数据很大(拆包),可能会多次触发该事件
Receive response响应头报文到达时触发
Send Request发送网络请求时触发

Script 事件

内容说明
Animation Frame Fired一个定义好的动画帧发生并开始回调处理时触发
Cancel Animation Frame取消一个动画帧触发
GC Event垃圾回收时触发
DOMContentLoaded当页面中的DOM内容加载并解析完触发
Evaluate ScriptA script was evaluated
EventJS事件
Function Call浏览器进入js引擎时触发
Install Timer创建定时器(调用setTimeout或者setInterval)时触发
Timer Fired定时器回调
Request Animation FrameA requestAnimationFrame() call scheduled a new frame
Remove Timer清除计时器时触发

Rendering 事件

内容说明
LayoutLayout
Update Layer Tree更新 Layer Tree

Painting 事件

内容说明
paint绘制
Schedule Style Recalculation样式更改

先执行onload事件,然后执行setTimeout,创建了一个定时器,然后进行layout(视图渲染)。 image.png

执行timer fired,执行test1()

image.png test1函数里,调用getElementById,更改了样式,调用test4()

image.png 在test4()后render前执行了test3中的微任务

image.png 可以注意一个现象,因为test4中有一个20ms的循环,阻塞了render的渲染,导致两次render间隔时间是大于16.7ms

image.png 假设我们把test4和test3注释掉,然后执行test2,不在同一事件循环中更改样式,中间间隔时间小于16.7ms只会引起一次render,验证不是每轮事件循环都会执行视图更新。

原理解析

  数据变化时,会执行Watcher的update方法执行数据更新,Watcher的update方法也是通过Vue.nextTick方法进行遍历执行,最终完成视图更新。

  那Vue.nextTick是什么呢?实际上也是一个异步函数,每次传进来的事件都会添加到callback回调中


return function queueNextTick (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()

    }

    if (!cb && typeof Promise !== 'undefined') {

      return new Promise((resolve, reject) => {

        _resolve = resolve

      })

    }

  }

  那timerFunc又是什么呢?

// 如果支持promise
if (typeof Promise !== 'undefined' && isNative(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 (!isIE && 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, iOS7, Android 4.4

    var counter = 1
    // 创建一个MutationObserver去监听任务的结束
    var observer = new MutationObserver(nextTickHandler)

    var textNode = document.createTextNode(String(counter))

    observer.observe(textNode, {

      characterData: true

    })

    timerFunc = () => {

      counter = (counter + 1) % 2
       // 用于监听dom更改
      textNode.data = String(counter)

    }

  } else {

    // fallback to setTimeout

    /* istanbul ignore next */

    timerFunc = () => {

      setTimeout(nextTickHandler, 0)

    }

  }

  从上面可以看出,在不同环境下对使用哪种异步方法做了处理,优先使用promise及MutationObserver(可以监听dom变化),这个两都是添加到微任务中。就好比我们排队买包子,包子是现做的,当有一笼包子好了,需要排队取,微任务是包子出炉就可以取,而宏任务却需要到下一笼包子。中间等时间可以看作UI Render,是在每次任务的最后,所以优先使用微任务,避免重复进行渲染。

  我们可以看到在使用MutationObserver时是自己创建了一个新DOM,然后去执行DOM更新,但是这跟我们的DOM更新有什么关系呢?因为js是单线程,任务队列中有着先进先出的规则。就如,我们去餐厅排队(任务队列),这时候需要取号(创建一个新dom的任务,监听dom的更改,添加到任务队列),当服务员叫号了我们(在我们创建的dom更之前的任务都执行完了,轮到dom更新完了), 我们可以进餐(轮到nextTic任务执行了),所以能保证DOM更新之后去执行这个回调。

  因为兼容问题,只能进行降级操作,使用setTimeout进行异步回调,此时是添加到了下一次任务队列中,会多进行一次UI Render。

总结

  vue为了确保性能问题,会把DOM修改添加到异步队列中,在所有的同步代码执行完后再统一修改DOM,一次事件循环中只会触发一次Watcher的Updete,也是通过nextTick进行异步创建,会优先使用microTask创建任务队列,如果要获取修改后的DOM,也是通过nextTick创建一个异步任务跟在DOM更新任务之后。