vue3和vue2的nextTick()源码解读

268 阅读6分钟

想必熟悉vue的小伙伴都知道nextTick()这个api,虽然文档对这个API的描述比较简单:

image.png

文档下面还有两行文字描述:

image.png

从这两行字我们大概可以知道两件事:一是Vue的DOM更新依赖于'nextTick'这个机制,二是nextTick()作为一个API暴露出来可以让我们使用'nextTick'机制。

简单介绍一下组件的响应式原理:

我们都知道Vue的组件状态变更后会触发组件渲染函数重新执行,生成新的virtual DOM,就像这样:

// vue3
effect(()=>{
// ctx是渲染上下文
const subtree = render.call(ctx)
})

effect(cb)首次执行会使cb依赖的响应式数据收集effect(cb),而effect(cb)是对cb()的包装,所以相当于响应式数据收集了cb。render()(也就是cb)依赖的响应式数据发生变化后,副作用函数会重新执行

但是现在render的执行是同步的,只要状态变化就会触发执行。但是一个事件循环内多次状态变化导致重复执行render是没啥必要的,只要最后一次就行,因此需要实现一个去重机制,并将不重复的任务缓冲到一个微任务队列中,等当前执行栈清空后,把任务取出来遍历执行

Vue2的nextTick():

vue2中也存在nextTick(),而且实现思路与vue3相同,它的代码在src/core/util/next-tick.ts

export let isUsingMicroTask = false

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

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]')
) {
  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(): Promise<void>
export function nextTick(cb: (...args: any[]) => any, ctx?: object): void
/**
 * @internal
 */
export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e: any) {
        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
    })
  }
}

可以看到代码不算很长, 一上来就定义了一个全局的callback数组和一个pending = false,其中callback是用来保存传给nextTick()的回调,pending是为了保存nextTick()的执行状态,可以理解为promise未确定时的状态'pending'。

然后就是第二个函数flushCallbacks,从它的名字可以看出:'冲走回调函数',说明它的任务是执行回调并清空保存的回调队列。执行它的时候pending = false,说明nextTick()已经不再pending了,然后创建了一份callbacks的浅拷贝,清空callbacks数组,最后遍历执行拷贝中的回调

再往下就是异步任务的具体实现的判断了,因为vue的渲染逻辑是平台无关的,只在初始化renderer的时候才配置好对应的Option,所以vue要判断平台具体可以使用哪种方式实现异步任务。从代码中我们可以看到,有Promise的话就初始化一个promise.resolve()的p,然后声明一个timerFunc给p添加一个包装flushCallbacks回调。没有promise的时候使用mutation observor来实现微任务队列,给他添加回调后然后观察一个文本节点,执行timerFunc的时候改变文本节点触发添加回调到微任务队列。然后后续的代码都是一些降级模拟实现微任务,当然,最次的选择就是使用setTimeout(cb,0)来模拟微任务了

然后就来到了最重要的nextTick函数了,它的第一个参数是回调,第二个参数ctx,从cb,call(ctx)来看,说明它是回调的上下文。

可以看到在nextTick中首先会把回调的包装函数保存到callbacks数组里。因为默认pending为false,首次执行便把pending设为True,也就是说nextTick已经处于pending状态然后执行timerFunc,这个函数我们之前提到过,无论它是如何实现的,执行它的时候都会在异步任务队列中添加一个flushCallbacks的回调。所以它的作用是在异步任务队列中取出回调执行。

后面还可以看到如果没有回调,那么nextTick会返回一个promise,这个promise啥时候resolve呢,答案还是在执行timerFunc的时候。如果没有cb,就会走到_resolve(ctx)这个分支里,因为执行栈退出之前resolve给了_resolve。所以在当前tick执行cb的时候会使当前所有nextTick返回的promise进入确定状态,按次序添加对应的回调,和传入cb其实是一样的,所以不得不佩服Vue这些精妙的设计。

到这里nextTick干了啥我们基本上就清楚了,所以接下来我们看看vue是怎么利用nextTick实现DOM异步更新的逻辑(代码太多不全贴了,在src/core/observer/scheduler.ts中):

export function queueWatcher(watcher: Watcher) {
  const id = watcher.id
  if (has[id] != null) {
    return
  }

  if (watcher === Dep.target && watcher.noRecurse) {
    return
  }

  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 (__DEV__ && !config.async) {
      flushSchedulerQueue()
      return
    }
    nextTick(flushSchedulerQueue)
  }
}

在vue2中使用watcher来通知render函数重新渲染,当watcher触发更新时,会使用这个queueWatcher来调度执行更新,也就是说每个watcher的更新都是调用queueWatcher然后把自己作为参数传入。

咱们往后看代码里做了啥,获取ID,然后根据ID去重。然后如果还未开始flush,那么往队列里添加watcher,如果已经开始flush那么就根据ID从小到大把他塞进队列里,然后waiting设为true,给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 (__DEV__ && 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
      }
    }
  }
}

currentFlushTimestamp是为了解决一个issue,暂且不用管他。然后开启了flush并对queue排序,排序一是为了保证父子组件的更新顺序,二是为了保证用户自己的响应式数据变更优先于render。然后接下来就是循环执行watcher.run(),真正执行render的更新了。需要注意的是,flushSchedulerQueue发生的一切都是在nextTick的callback数组的一个cb中,而这个cb是在微任务队列中取出来执行的

看到这我们就了解了Vue是怎么使用nextTick完成DOM在异步任务队列中更新的了

nextTick还有一个问题就是我们使用nextTick()这个API的时候,回调入队顺序问题,比如下面这种情况

this.$nextTick(() => console.log('1'))
//修改状态
this.state = 'nextTick' 
this.$nextTick(() => console.log('2'))

因为我们之前已经分析过了,所以这里就很容易可以知道,因为callback数组是全局的,所以先添加第一个回调() => console.log('1'),然后第二个回调是组件派发更新的flushSchedulerQueue,然后第三个回调是() => console.log('2'),在本轮事件循环结束的时候依次取出并执行他们

Vue3中的nextTick():

Vue3中和nextTick和组件更新的sceduler合并了,在runtimeCore/src/scheduler.ts中。太长就不贴出来了,先看文件中的第一个函数:

const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null

export function nextTick<T = void>(
  this: T,
  fn?: (this: T) => void
): Promise<void> {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

如果传入了回调fn就给全局的resolvedPromise添加回调,同时绑定this,如果没有就直接返回p。和vue2的nextTick原理基本一样。

// #2768
// Use binary-search to find a suitable position in the queue,
// so that the queue maintains the increasing order of job's id,
// which can prevent the job from being skipped and also can avoid repeated patching.
function findInsertionIndex(id: number) {
  // the start index should be `flushIndex + 1`
  let start = flushIndex + 1
  let end = queue.length

  while (start < end) {
    const middle = (start + end) >>> 1
    const middleJobId = getId(queue[middle])
    middleJobId < id ? (start = middle + 1) : (end = middle)
  }

  return start
}

然后是findInsertionIndex,这个方法主要是通过任务的ID来判断任务应该插入的位置,维持队列中ID的排列顺序(参考vue2中的queueWatcher中的queue.splice(i + 1, 0, watcher))。

export function queueJob(job: SchedulerJob) {
  // the dedupe search uses the startIndex argument of Array.includes()
  // by default the search index includes the current job that is being run
  // so it cannot recursively trigger itself again.
  // if the job is a watch() callback, the search will start with a +1 index to
  // allow it recursively trigger itself - it is the user's responsibility to
  // ensure it doesn't end up in an infinite loop.
  if (
    (!queue.length ||
      !queue.includes(
        job,
        isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
      )) &&
    job !== currentPreFlushParentJob
  ) {
    if (job.id == null) {
      queue.push(job)
    } else {
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    queueFlush()
  }
}

只要queue为空或者queue不包含当前job(当允许递归自己时从index+1开始找有没有这个job,相当于允许自己重复添加。当不允许递归就从当前job开始找),job !== currentPreFlushParentJob可能是为了处理某种边界情况,这种情况下刷新前置队列时会带着一个parentJob。然后如果job没有Id那么就直接入队,如果有id就根据id入队,然后调用queueFlush()刷新队列。那么queueFlush又做啥了捏?

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

isFlushing是then的回调开始执行时的标志,说明已经开始刷新队列了。isFlushPending是说明这时候已经添加了微任务队列了但是还未执行,当这二者都为false把flushJobs添加到微任务队列同时isFlushPending = true。继续看flushJobs:

type CountMap = Map<SchedulerJob, number>

function flushJobs(seen?: CountMap) {
  isFlushPending = false
  isFlushing = true
  if (__DEV__) {
    seen = seen || new Map()
  }

  flushPreFlushCbs(seen)

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child so its render effect will have smaller
  //    priority number)
  // 2. If a component is unmounted during a parent component's update,
  //    its update can be skipped.
  queue.sort((a, b) => getId(a) - getId(b))

  // conditional usage of checkRecursiveUpdate must be determined out of
  // try ... catch block since Rollup by default de-optimizes treeshaking
  // inside try-catch. This can leave all warning code unshaked. Although
  // they would get eventually shaken by a minifier like terser, some minifiers
  // would fail to do that (e.g. https://github.com/evanw/esbuild/issues/1610)
  const check = __DEV__
    ? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job)
    : NOOP

  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job && job.active !== false) {
        if (__DEV__ && check(job)) {
          continue
        }
        // console.log(`running:`, job.id)
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    flushIndex = 0
    queue.length = 0

    flushPostFlushCbs(seen)

    isFlushing = false
    currentFlushPromise = null
    // some postFlushCb queued jobs!
    // keep flushing until it drains.
    if (
      queue.length ||
      pendingPreFlushCbs.length ||
      pendingPostFlushCbs.length
    ) {
      flushJobs(seen)
    }
  }
}

这个函数有一个形参seen,类型是countMap,可以看出来这东西是为了统计Job的出现次数,用来处理递归的情况,如果出现次数大于最大递归次数就抛出警告。

当这个函数执行时pending状态就结束了,然后isFlush开启。然后是flushPreFlushCbs(seen)。vue在调度任务的时候,以render effect的执行为界限,在这之前的时前置任务,之后的是后置任务,也就是flushPreFlushCbs和flushPostFlushCbs,这个一会再看。继续往下是对队列进行排序,这一点和Vue2的watcher排序相同。一般来说有排序就意味着可以插队,之所以要插队是因为组件的更新父组件优先级要高于子组件,因为父组件会改变子组件状态,父组件更新后的子组件状态才是正确的,在这个基础上再进行更新。

queueJobs中的queue.splice(findInsertionIndex(job.id), 0, job)这句代码中就是插队用的代码,然后就是喜闻乐见的遍历queue执行回调。Vue3中,函数的DOM更新任务都会添加到queue这个队列中,添加方式和vue2类似,把对应的ReactiveEffect实例保存在queue数组(vue2中是watcher)。所以这里的任务执行完后DOM就完成了更新。注意这里的_DEV_,如果是开发模式会检查是否超过了允许的最大递归深度。到最后是一些后处理过程,重置flushIndex,清空queue重置isflushing状态等等。最后是一个判断,无论哪个队列没清空,都要递归的刷新flushJob直到为空。

现在还有PreFlushCbs和PostFlushCbs相关的函数还没看:

function queueCb(
  cb: SchedulerJobs,
  activeQueue: SchedulerJob[] | null,
  pendingQueue: SchedulerJob[],
  index: number
) {
  if (!isArray(cb)) {
    if (
      !activeQueue ||
      !activeQueue.includes(cb, cb.allowRecurse ? index + 1 : index)
    ) {
      pendingQueue.push(cb)
    }
  } else {
    // if cb is an array, it is a component lifecycle hook which can only be
    // triggered by a job, which is already deduped in the main queue, so
    // we can skip duplicate check here to improve perf
    pendingQueue.push(...cb)
  }
  queueFlush()
}

export function queuePreFlushCb(cb: SchedulerJob) {
  queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
}

export function queuePostFlushCb(cb: SchedulerJobs) {
  queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex)
}

export function flushPreFlushCbs(
  seen?: CountMap,
  parentJob: SchedulerJob | null = null
) {
  if (pendingPreFlushCbs.length) {
    currentPreFlushParentJob = parentJob
    activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
    pendingPreFlushCbs.length = 0
    if (__DEV__) {
      seen = seen || new Map()
    }
    for (
      preFlushIndex = 0;
      preFlushIndex < activePreFlushCbs.length;
      preFlushIndex++
    ) {
      if (
        __DEV__ &&
        checkRecursiveUpdates(seen!, activePreFlushCbs[preFlushIndex])
      ) {
        continue
      }
      activePreFlushCbs[preFlushIndex]()
    }
    activePreFlushCbs = null
    preFlushIndex = 0
    currentPreFlushParentJob = null
    // recursively flush until it drains
    flushPreFlushCbs(seen, parentJob)
  }
}

export function flushPostFlushCbs(seen?: CountMap) {
  // flush any pre cbs queued during the flush (e.g. pre watchers)
  flushPreFlushCbs()
  if (pendingPostFlushCbs.length) {
    const deduped = [...new Set(pendingPostFlushCbs)]
    pendingPostFlushCbs.length = 0

    // #1947 already has active queue, nested flushPostFlushCbs call
    if (activePostFlushCbs) {
      activePostFlushCbs.push(...deduped)
      return
    }

    activePostFlushCbs = deduped
    if (__DEV__) {
      seen = seen || new Map()
    }

    activePostFlushCbs.sort((a, b) => getId(a) - getId(b))

    for (
      postFlushIndex = 0;
      postFlushIndex < activePostFlushCbs.length;
      postFlushIndex++
    ) {
      if (
        __DEV__ &&
        checkRecursiveUpdates(seen!, activePostFlushCbs[postFlushIndex])
      ) {
        continue
      }
      activePostFlushCbs[postFlushIndex]()
    }
    activePostFlushCbs = null
    postFlushIndex = 0
  }
}

第一个函数queueCb就和它的名字一样,把回调缓存到队列中,逻辑和之前的queueJobs类似,但是它最后也会调用queueFlush(),也就是说回调缓冲到前置队列和后置队列的过程都会将flushJobs添加到微任务队列。然后是queuePreFlushCb和queuePostFlushCb,这两个向外暴露的方法可以让我们选择任务的执行时机:参阅文档的副作用刷新一节,watch回调的刷新默认是pre,也就是watch的回调被缓存在了pendingPreFlushCbs中,在组件update之前执行。另外queueCb中的cb是数组的话,它只可能是生命周期钩子,我在源码找了一圈,发现queuePostFlushCb中执行声明周期钩子数组的,有可能是unmount和activated和deactivated这三个。

然后是flushPostFlushCbs和flushPreFlushCbs这两个函数,可以看出来flushPreFlushCbs先清空pendingPreFlushCbs,然后用复制的activePreFlushCbs队列遍历执行,这个执行过程会产生更多的前置队列回调加入到到pendingPreFlushCbs,所以递归执行flushPreFlushCbs以清空新产生的任务。但是flushPostFlushCbs却没有这个递归的过程。post队列执行时,产生的任务可能加入pre,main和post,这点和pre不同,pre最先执行,即使产生了post和main队列的任务,后续也是可以执行的。所以post队列直接再下一次flushJobs中执行

然后还有一个工具函数虽然恨不起眼但是很重要:

export function invalidateJob(job: SchedulerJob) {
  const i = queue.indexOf(job)
  if (i > flushIndex) {
    queue.splice(i, 1)
  }
}

作用是删除主队列中的更新任务,我们知道父组件更新时有可能会导致子组件的被动更新,父组件执行instance.update()如果props发生了变化那么子组件也会运行instance.update()。那么这时候父组件在这个更新过程中会直接删除队列中这个子组件的job,然后同步的取出来执行:

// in case the child component is also queued, remove it to avoid
// double updating the same child component in the same flush.
invalidateJob(instance.update)
// instance.update is the reactive effect.
instance.update()

这样就可以避免父组件更新子组件的时候执行一次instance.update,队列中已经存在的相同子组件任务又执行了一次,造成的重复更新

然后这个文件的最后一个函数:

function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) {
  if (!seen.has(fn)) {
    seen.set(fn, 1)
  } else {
    const count = seen.get(fn)!
    if (count > RECURSION_LIMIT) {
      const instance = fn.ownerInstance
      const componentName = instance && getComponentName(instance.type)
      warn(
        `Maximum recursive updates exceeded${
          componentName ? ` in component <${componentName}>` : ``
        }. ` +
          `This means you have a reactive effect that is mutating its own ` +
          `dependencies and thus recursively triggering itself. Possible sources ` +
          `include component template, render function, updated hook or ` +
          `watcher source function.`
      )
      return true
    } else {
      seen.set(fn, count + 1)
    }
  }
}

这个函数就是我们提到的,检查是否递归深度超过最大深度,就和警告上说的一样,在副作用函数中更改依赖的状态会导致状态又触发副作用函数,然后又进行状态更改,由此导致了递归。

最后再梳理一下vue3中的nextTick(),可以发现相关的逻辑大部分都移植到了scheduler中,vue2中的全局callBack已经被原生promise语法取代了,只要调用nextTick给resolvedPomise添加回调,回调自然会按照回调添加的顺序排列。但是在scheduler这块的思路还是不变的,当queue中添加第一个回调的时候开启队列刷新(添加一个微任务),后续可以在当前事件循环的队列添加多个任务,清空执行栈后在这个微任务中取出来遍历执行。但是vue3同时又做出了很多改进,使用三个队列pre,main,post来让用户控制不同副作用函数的执行顺序,同时还处理了很多边界情况。

nextTick()表面上看起来是一个普通API,但是背后隐藏的是Vue严谨的调度系统,所以值得仔细研究

参考:mp.weixin.qq.com/s/w-Jsb1Rpy…