3

前言

我们都知道vue是数据驱动视图,而vue中视图更新是异步的。在业务开发中,有没有经历过当改变了数据,视图却没有按照我们的期望渲染?而需要将对应的操作放在nextTick中视图才能按照预期的渲染,有的时候nextTick也不能生效,而需要利用setTimeout来解决?

搞清楚这些问题,那么就需要搞明白以下几个问题:
1、vue中到底是如何来实现异步更新视图;
2、vue为什么要异步更新视图;
3、nextTick的原理;
4、nextTick如何来解决数据改变视图不更新的问题的;
5、nextTick的使用场景。

以下分享我的思考过程。

Vue中的异步更新DOM

Vue中的视图渲染思想

vue中每个组件实例都对应一个watcher实例,它会在组件渲染的过程中把“接触”过的数据属性记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

如果对vue视图渲染的思想还不是很清楚,可以参考这篇defineProperty实现视图渲染用defineProty模拟的Vue的渲染视图,来了解整个视图渲染的思想。

Vue异步渲染思想和意义

但是Vue的视图渲染是异步的,异步的过程是数据改变不会立即更新视图,当数据全部修改完,最后再统一进行视图渲染。

image.png

在渲染的过程中,中间有一个对虚拟dom进行差异化的计算过程(diff算法),大量的修改带来频繁的虚拟dom差异化计算,从而导致渲染性能降低,异步渲染正是对视图渲染性能的优化。

Vue异步渲染视图的原理

  • 依赖数据改变就会触发对应的watcher对象中的update
 /**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
  • 默认的调用queueWatcher将watcher对象加入到一个队列中
/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      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 > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

当第一次依赖有变化就会调用nextTick方法,将更新视图的回调设置成微任务或宏任务,然后后面依赖更新对应的watcher对象都只是被加入到队列中,只有当nextTick回调执行之后,才会遍历调用队列中的watcher对象中的更新方法更新视图。

这个nextTick和我们在业务中调用的this.$nextTick()是同一个函数。

if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }

flushSchedulerQueue刷新队列的函数,用于更新视图

function flushSchedulerQueue () {
  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]
    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到底是个什么东西呢?

nextTick的原理

vue 2.5中nextTick的源码如下(也可以跳过源码直接看后面的demo,来理解nextTick的用处):

/**
 * Defer a task to execute it asynchronously.
 */
export const nextTick = (function () {
  const callbacks = []
  let pending = false
  let timerFunc

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

  // An asynchronous deferring mechanism.
  // In pre 2.4, we used to use microtasks (Promise/MutationObserver)
  // but microtasks actually has too high a priority and fires in between
  // supposedly sequential events (e.g. #4521, #6690) or even between
  // bubbling of the same event (#6566). Technically setImmediate should be
  // the ideal choice, but it's not available everywhere; and the only polyfill
  // that consistently queues the callback after all DOM events triggered in the
  // same loop is by using MessageChannel.
  /* istanbul ignore if */
  if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = () => {
      setImmediate(nextTickHandler)
    }
  } else if (typeof MessageChannel !== 'undefined' && (
    isNative(MessageChannel) ||
    // PhantomJS
    MessageChannel.toString() === '[object MessageChannelConstructor]'
  )) {
    const channel = new MessageChannel()
    const port = channel.port2
    channel.port1.onmessage = nextTickHandler
    timerFunc = () => {
      port.postMessage(1)
    }
  } else
  /* istanbul ignore next */
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    // use microtask in non-DOM environments, e.g. Weex
    const p = Promise.resolve()
    timerFunc = () => {
      p.then(nextTickHandler)
    }
  } else {
    // fallback to setTimeout
    timerFunc = () => {
      setTimeout(nextTickHandler, 0)
    }
  }

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

用下面这个demo来感受依赖更新时和nextTick的关系以及nextTick的用处:

 function isNative(Ctor) {
     return typeof Ctor === 'function' && /native code/.test(Ctor.toString())
 }

 const nextTick = (function () {
     let pending = false;
     let callbacks = []
     let timerFunc

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

     if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
         timerFunc = () => {
             setImmediate(nextTickHandler)
         }
     } else if (typeof MessageChannel !== 'undefined' && (
             isNative(MessageChannel) ||
             // PhantomJS
             MessageChannel.toString() === '[object MessageChannelConstructor]'
         )) {
         const channel = new MessageChannel()
         const port = channel.port2
         channel.port1.onmessage = nextTickHandler
         timerFunc = () => {
             port.postMessage(1)
         }
     } else
         /* istanbul ignore next */
         if (typeof Promise !== 'undefined' && isNative(Promise)) {
             // use microtask in non-DOM environments, e.g. Weex
             const p = Promise.resolve()
             timerFunc = () => {
                 p.then(nextTickHandler)
             }
         } else {
             // fallback to setTimeout
             timerFunc = () => {
                 setTimeout(nextTickHandler, 0)
             }
         }

     console.log('timerFunc:', timerFunc)
     return function queueNextTick(cb, ctx) {
         callbacks.push(() => {
           if (cb) {
             cb.call(ctx)
            }
         })
         // console.log('callbacks:', callbacks)
         if (!pending) {
             pending = true
             console.log('pending...', true)
             timerFunc()
         }
     }
 })()

 //  模拟异步视图更新
 // 第一次先将对应新值添加到一个数组中,然后调用一次nextTick,将读取数据的回调作为nextTick的参数
 // 后面的新值直接添加到数组中
 console.time()
 let arr = []
 arr.push(99999999)
 nextTick(() => {
     
     console.log('nextTick one:', arr, arr.length)
 })

 function add(len) {
     for (let i = 0; i < len; i++) {
         arr.push(i)
         console.log('i:', i)
     }
 }

 add(4)
 //  console.timeEnd()
 //  add()
 //  add()
 nextTick(() => {
     arr.push(888888)
     console.log('nextTick two:', arr, arr.length)
 })
 add(8)的值之后
 console.timeEnd()

在chrome运行结果如下:
image.png

可以看到第二个nextTick中push的值最后渲染在add(8)的值之后,这也就是nextTick的作用了,nextTick的作用就是用来处理需要在数据更新(在vue中手动调用nextTick时对应的是dom更新完成后)完才执行的操作。

image.png

nextTick的原理:
首先nextTick会将外部传进的函数回调存在内部数组中,nextTick内部有一个用来遍历这个内部数组的函数nextTickHandler,而这个函数的执行是异步的,什么时候执行取决于这个函数是属于什么类型的异步任务:微任务or宏任务。

主线程执行完,就会去任务队列中取任务到主线程中执行,任务队列中包含了微任务和宏任务,首先会取微任务,微任务执行完就会取宏任务执行,依此循环。nextTickHandler设置成微任务或宏任务就能保证其总是在数据修改完或者dom更新完然后再执行。(js执行机制可以看promise时序问题&js执行机制

为什么vue中对设置函数nextTickHandler的异步任务类型会有如下几种判断?

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
         timerFunc = () => {
             setImmediate(nextTickHandler)
         }
     } else if (typeof MessageChannel !== 'undefined' && (
             isNative(MessageChannel) ||
             // PhantomJS
             MessageChannel.toString() === '[object MessageChannelConstructor]'
         )) {
         const channel = new MessageChannel()
         const port = channel.port2
         channel.port1.onmessage = nextTickHandler
         timerFunc = () => {
             port.postMessage(1)
         }
     } else
         /* istanbul ignore next */
         if (typeof Promise !== 'undefined' && isNative(Promise)) {
             // use microtask in non-DOM environments, e.g. Weex
             const p = Promise.resolve()
             timerFunc = () => {
                 p.then(nextTickHandler)
             }
         } else {
             // fallback to setTimeout
             timerFunc = () => {
                 setTimeout(nextTickHandler, 0)
             }
         }

浏览器环境中常见的异步任务种类,按照优先级:

  • macro task:同步代码、setImmediateMessageChannelsetTimeout/setInterval
  • micro taskPromise.thenMutationObserver

而为什么最后才判断使用setTimeout?
vue中目的就是要尽可能的快地执行回调渲染视图,而setTimeout有最小延迟限制:如果嵌套深度超过5级,setTimeout(回调,0)就会有4ms的延迟。

image.png

所以首先选用执行更快的setImmediate,但是setImmediate有兼容性问题,目前只支持Edge、Ie浏览器:

image.png

可以用同样执行比setTimeout更快的宏任务MessageChannel来代替setImmediate。MessageChannel兼容性如下:

image.png

当以上都不支持的时候,就使用new Promise().then(),将回调设置成微任务,Promise不支持才使用setTimeout。

总结:

nextTick就是利用了js机制执行任务的规则,将nextTick的回调函数设置成宏任务或微任务来达到在主线程的操作执行完,再执行的目的。

在vue中主要提供对依赖Dom更新完成后再做操作的情况的支持

nextTick的使用场景

当改变数据,视图没有按预期渲染时;都应该考虑是否是因为本需要在dom执行完再执行,然而实际却在dom没有执行完就执行了代码,如果是就考虑使用将逻辑放到nextTick中,有的时候业务操作复杂,有些操作可能需要更晚一些执行,放在nextTick中仍然没有达到预期效果,这个时候可以考虑使用setTimeout,将逻辑放到宏任务中。

基于以上分析,可以列举几个nextTick常用到的使用场景:

  • 在created、mounted等钩子函数中使用时。
  • 对dom进行操作时,例如:使用$ref读取元素时
        // input 定位
        scrollToInputBottom() {
            this.$nextTick(() => {
                this.$refs.accept_buddy_left.scrollTop =
                    this.$refs.accept_buddy_left.scrollTop + 135
                this.$refs.accept_buddy_ipt[
                    this.$refs.accept_buddy_ipt.length - 1
                ].$refs.ipt.focus()
            })
        },
  • 计算页面元素高度时:
        // 监听来自 url 的期数变化,跳到该期数
        urlInfoTerm: {
            immediate: true,
            handler(val) {
                
                if (val !== 0) {
                    this.$nextTick(function() {
                        //     计算期数所在位置的高度
                        this.setCellsHeight()
                        //设置滚动距离
                        this.spaceLenght = this.getColumnPositionIndex(
                            this.list,
                        )
                        setTimeout(() => {
                            this.setScrollPosition(val)
                        }, 800)
                    })
                }
            },

参考资料

【Vue源码】Vue中DOM的异步更新策略以及nextTick机制

异步更新队列

nextTick原理

MessageChannel


贝er
58 声望6 粉丝

不仅仅是程序员