Vue中的nextTick机制

251 阅读5分钟

前言


相信对于每一个使用Vue的前端同学来说,nextTick这个方法并不陌生,作用就是在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。 在开始往下阅读之前,如果有对JS浏览器的事件循环机制不清楚的同学,可以点击这里进行了解。此外,希望大家能够对下图有足够的认识,以便理解nextTick运行机制。vue@2.6.x版本进行分析 Snipaste_2020-07-29_16-38-48.png

在进入我们今天的主角nextTick讲解之前,我们先简单地介绍一下关于nextTick执行的整体流程,以便大家能够更好地从整体上去把握、理解。(以下的代码模块,都只截取了跟nextTick相关的核心部分)

Vue初始化


在Vue初始化过程中,会执行原型上的_init方法,进行一系列的初始化操作,最后会调用$mount方法。

export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    ... // 不关注的部分暂缺忽略掉
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

通过调用$mount方法,最后会调用mountComponent方法,有关代码如下

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  // ...
  callHook(vm, 'beforeMount')
  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = () => {
    // ...
    }
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  // ...
  return vm
}

执行mountComponent方法中,可以直接看到,会对updateComponent方法进行赋值,这个方法用于虚拟DOM映射成真实DOM,视图的更新,我们暂且知道这一点就行了。另一个核心的函数就是执行了newWatcher,而mountComponent会作为一个参数传入Watcher中,用于首次渲染和之后的派发更新时触发的重新渲染,且每一个Vue实例会有拥有一个唯一的渲染Watcher。我们接下来看看Watcher的相关实现

Watcher


export default class Watcher {
  // ...
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    // ...
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } 
    this.value = this.lazy
      ? undefined
      : this.get()
  }

  get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      popTarget()
      this.cleanupDeps()
    }
    return value
  }
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }

  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
  run () {
    if (this.active) {
      const value = this.get()
      if (
        value !== this.value ||
        // Deep watchers and watchers on Object/Arrays should fire even
        // when the value is the same, because the value may
        // have mutated.
        isObject(value) ||
        this.deep
      ) {
        // set new value
        const oldValue = this.value
        this.value = value
        if (this.user) {
          try {
            this.cb.call(this.vm, value, oldValue)
          } catch (e) {
            handleError(e, this.vm, `callback for watcher "${this.expression}"`)
          }
        } else {
          this.cb.call(this.vm, value, oldValue)
        }
      }
    }
  }
// ...
}

我们主要关注get、run、update方法,其余不重要的我们暂且不关注,对于我们当前的渲染Watcher来说,get方法其实就是updateComponent方法,run方法其实就是执行一遍get方法, update方法我们之后再结合例子进行讨论。

DefineReactive


vue响应式的核心代码如下,相关代码

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()
  // ....
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        // ....
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      // ....
      dep.notify()
    }
  })
}

defineReactive中,会重写每一个key对应的get与set方法,且每一个key会维护一个唯一的Dpe实例,那什么时候进行依赖收集了呢?还记得我上面说到在mountComponent中会执行new Watcher(....)吗?

其实在new Watcher的过程中就会取执行传入updateComponent方法 收集依赖: 这个时候就会在执行render函数生成虚拟DOM时,读取到数据,即触发了get操作,会用过Watcher的addDpe方法,把当前的渲染Watcher加入到了每个key值对应Dep实例下的subs数组中。

派发更新: 另外,再改变数据的值时,就会触发set函数,就会遍历Dpe实例下的subs数组,执行每一个watcher的update方法,关于Dpe的核心代码如下:

export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;
  constructor () {
    this.id = uid++
    this.subs = []
  }
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
   // ...
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}
Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

相信到这里,大家应该对vue如何初初始化及更新视图及Watcher及Dep的作用有了一定的了解了,接下来我们来着重分析一下nextTick源码入口

/* @flow */
/* globals MutationObserver */

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'

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]()
  }
}

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) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  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)) {
  // Fallback to setImmediate.
  // Technically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  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()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

我们只分析传入fn的情况,返回Promise的情况类似,大家可以自行分析。 首先nextTick的定义开始分析,nextTick可以接受一个回调函数fn,并且每次执行时会通过在外包裹一层匿名函数的方式push到callbacks的任务队列中,接下来判断pending的值,如果pending为false,则会执行timerFunc,从代码中可以看到timerFunc实现的优先级为Promise.then > MutationObserver > setImmediate > setTimeout,我们假设timerFunc基于Promise.then实现,来进行分析。

测试代码

<template>
    <div @click="onClick" ref="target">{{ name }}</div>
</template>

<script>
    export default {
        data () {
        	return {
        	    name: 'small-zsj'
        	}
        },
    	methods: {
    	    onClick () {
    	        this.name = 'big-zsj' // 同步任务
                console.log(this.$refs.target.innerText) // 'small-zsj' 
        	this.$nextTick(() => {
                    console.log(this.$refs.target.innerText) // 'big-zsj' 更新之后的DOM
                })
    	    }
    	}
    }
</script>
    
}

当我们点击目标元素的时候,触发了onClick事件,随后修改了this.name的值,这个时候就会触发name的setter,接着会执行 dep.notify() -> watcher(渲染).update -> queueWatcher,接下来我们来看下queueWatcher这个函数的实现

const queue: Array<Watcher> = []
const activatedChildren: Array<Component> = []
let has: { [key: number]: ?true } = {}
let circular: { [key: number]: number } = {}
let waiting = false
let flushing = false
let index = 0

/**
 * Reset the scheduler's state.
 */
function resetSchedulerState () {
  index = queue.length = activatedChildren.length = 0
  has = {}
  if (process.env.NODE_ENV !== 'production') {
    circular = {}
  }
  waiting = flushing = false
}

export let currentFlushTimestamp = 0

let getNow: () => number = Date.now

if (inBrowser && !isIE) {
  const performance = window.performance
  if (
    performance &&
    typeof performance.now === 'function' &&
    getNow() > document.createEvent('Event').timeStamp
  ) {
    getNow = () => performance.now()
  }
}

/**
 * Flush both queues and run the watchers.
 */
function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id

  queue.sort((a, b) => a.id - b.id)

  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
      }
    }
  }
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()
  resetSchedulerState()
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}

function callUpdatedHooks (queue) {
  let i = queue.length
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'updated')
    }
  }
}

export function queueActivatedComponent (vm: Component) {
  // setting _inactive to false here so that a render function can
  // rely on checking whether it's in an inactive tree (e.g. router-view)
  vm._inactive = false
  activatedChildren.push(vm)
}

function callActivatedHooks (queue) {
  for (let i = 0; i < queue.length; i++) {
    queue[i]._inactive = true
    activateChildComponent(queue[i], true /* true */)
  }
}

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

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}

从代码中可以看到,会对每个watcher的id进行判断,如果已经存在了就直接return,比如循环1000次加1的操作,修改数据,vue并不会把setter->dep.notify()->watcher.update->watcher.run执行1000次,因为有id的判断,所以只需要执行一次就够了,这在性能上是一个很大的提升,最后代码执行之后会调用nextTick(flushSchedulerQueue),其实就是把更新视图的操作的函数作为参数传入了nextTick中(异步队列),把flushSchedulerQueue也push到了callbacks数组中。相当于执行了如下代码:

<template>
    <div @click="onClick" ref="target">{{ name }}</div>
</template>

<script>
    export default {
        data () {
        	return {
        	    name: 'small-zsj'
        	}
        },
    	methods: {
    	    onClick () {
    	        this.$nextTick(() => {
    	            console.log(this.$refs.target.innerText) // 'small-zsj'
    	            // 因为callbacks先push了当前函数,但是更新DOM的操作是在this.name = 'big-zsj'之后
    	        })
    	        this.name = 'big-zsj' // 同步任务
    	        this.$nextTick(() => {
                    // 更新DOM,执行watcher.run()
                })
                console.log(this.$refs.target.innerText) // 'small-zsj' 
        	this.$nextTick(() => {
                    console.log(this.$refs.target.innerText) // 'big-zsj' 更新之后的DOM
                })
    	    }
    	}
    }
</script>
    
}

因为nextTick是就Promise.then实现的,所以会在同步任务结束后,从微任务队列里面去遍历执行,只要保证nextTick是在数据改变后执行,那么拿到的DOM就是更新后的DOM(因为callbacks队列是顺序执行的)。 Snipaste_2020-07-29_16-38-48.png注意,DOM更新跟视图渲染不是同一个概念,如图,在JS引擎执行完微队列的所有任务后,浏览器会将JS进程挂起,此时渲染进程会开始工作,对视图进行更新渲染,所以DOM的更新操作应尽可能的放在微队列中进行批量处理,提高效率。

以上便是我对nextTick的一些理解,如有不足或者不对的地方,还望指出!!

参考链接