Vue nextTick 的原理源码分析

517 阅读2分钟

基本语法

nextTick(callback);

基础使用

Vue 2 中使用 nextTick

基本用法:

<template>
  <div>
    <button @click="updateData">更新数据</button>
    <p>{{ message }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: '初始值'
    };
  },
  methods: {
    updateData() {
      this.message = '更新后的值';

      // 使用 $nextTick 确保 DOM 更新
      this.$nextTick(() => {
        console.log('DOM 已更新,当前 message 内容:', this.message);
      });
    }
  }
};
</script>

返回 Promise:

this.$nextTick()
  .then(() => {
    console.log('DOM 已更新,当前 message 内容:', this.message);
  });

Vue 3 中使用 nextTick

在 Vue 3 中,nextTick 依然存在,但由于响应式系统的改进,使用上更加灵活。

基本用法:

<template>
  <div>
    <button @click="updateData">更新数据</button>
    <p>{{ message }}</p>
  </div>
</template>

<script>
import { nextTick } from 'vue';

export default {
  data() {
    return {
      message: '初始值'
    };
  },
  methods: {
    updateData() {
      this.message = '更新后的值';

      // 使用 nextTick 确保 DOM 更新
      nextTick(() => {
        console.log('DOM 已更新,当前 message 内容:', this.message);
      });
    }
  }
};
</script>

返回 Promise:

nextTick()
  .then(() => {
    console.log('DOM 已更新,当前 message 内容:', this.message);
  });

作用

在 Vue 2 中,nextTick 方法用于在下次 DOM 更新循环结束之后执行延迟回调。这通常用于在响应式数据更改后,确保 DOM 已经更新。

在 Vue 3 中,nextTick 的实现主要是为了确保在 DOM 更新后执行特定的回调。这是通过 scheduler 和 flushCallbacks 等机制来调度和执行回调的。

vue2源码体现

Vue 2 中的 nextTick 实现原理主要基于 JavaScript 的异步编程模型,利用微任务(Microtasks)来确保在 DOM 更新后执行一些操作。

1. 微任务与宏任务

  • 微任务:微任务是在当前任务完成后、下一次事件循环执行之前执行的任务。它通常使用 PromiseMutationObserver 或 process.nextTick(在 Node.js 中)等机制实现。
  • 宏任务:例如 setTimeoutsetInterval 等,它们的执行在微任务之后。

Vue 2 的 nextTick 主要依赖于微任务的特性,以确保 DOM 更新在回调执行前完成。

2. 代码实现

在 Vue 2 的 nextTick 实现中,基本步骤如下:

  • 回调队列:定义一个数组 callbacks 用于存储待执行的回调函数。当用户调用 nextTick 时,将提供的回调函数添加到这个数组中。
  • 执行机制:使用一个定时器函数 timerFunc 来处理回调。这个函数决定了如何执行这些回调,通常会选择最佳的执行机制(如 Promise.then 或 MutationObserver)。
  • 回调执行:当 nextTick 被调用,若没有 pending(标识是否有待执行的任务),则将 pending 设置为 true,并启动定时器函数。在定时器函数中,所有存储的回调函数将被逐一执行。
  • 错误处理:在执行回调时,vue 还会包裹 try-catch 以捕获可能的错误,确保一个回调的失败不会影响其他回调的执行。

3. 实现流程

以下是 Vue 2 nextTick 的具体实现流程:

  1. 用户调用 nextTick:将传入的回调函数压入 callbacks 数组中。

  2. 检查 pending:如果 pending 为 false,则表明没有正在执行的回调,接下来将其设置为 true,并调用 timerFunc

  3. 定时器触发

    • 在定时器函数中,使用适当的方法(如 Promise 或 MutationObserver)将 flushCallbacks 离子执行加入微任务队列。
    • flushCallbacks 函数负责清空 callbacks 数组,并执行所有存储的回调。
  4. 回调执行:依次调用 callbacks 中的每个函数,并在调用过程中进行错误处理。

// 源码位置 src\core\util\next-tick.ts
import { noop } from 'shared/util' // 导入一个空函数 noop,用于无操作
import { handleError } from './error' // 导入一个用于处理错误的函数
import { isIE, isIOS, isNative } from './env' // 导入环境相关的检查函数

export let isUsingMicroTask = false // 标识当前是否使用微任务

const callbacks: Array<Function> = [] // 用于存储待执行的回调函数
let pending = false // 标识当前是否有待执行的任务

// 刷新并执行所有回调函数
function flushCallbacks() {
  pending = false // 设置 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)) {
  // 如果支持 Promise,使用 Promise.then
  const p = Promise.resolve() // 创建一个立即解决的 Promise
  timerFunc = () => {
    p.then(flushCallbacks) // 在 Promise 解析时执行 flushCallbacks
    if (isIOS) setTimeout(noop) // 针对 iOS 的某些问题强制刷新微任务
  }
  isUsingMicroTask = true // 标识使用微任务
} else if (
  !isIE && // 不是 IE 浏览器
  typeof MutationObserver !== 'undefined' && // 支持 MutationObserver
  (isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
  // 使用 MutationObserver
  let counter = 1
  const observer = new MutationObserver(flushCallbacks) // 创建 MutationObserver 观察器
  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)) {
  // 如果支持 setImmediate,作为后备方案
  timerFunc = () => {
    setImmediate(flushCallbacks) // 使用 setImmediate 刷新回调
  }
} else {
  // 如果以上都不支持,使用 setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0) // 使用 setTimeout 刷新回调
  }
}

// 定义 nextTick 函数的重载
export function nextTick(): Promise<void>
export function nextTick<T>(this: T, cb: (this: T, ...args: any[]) => any): void
export function nextTick<T>(cb: (this: T, ...args: any[]) => any, ctx: T): 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) // 如果没有回调但需要解析 Promise,调用 resolve
    }
  })
  
  if (!pending) {
    pending = true // 标记为正在处理
    timerFunc() // 开始执行定时器
  }

  // 如果没有回调并且支持 Promise,返回一个新的 Promise
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve // 保存解析函数
    })
  }
}

vue3中源码体现

Vue 3 中的 nextTick 原理主要基于任务调度和异步数据更新机制,旨在确保在 DOM 更新后执行回调函数。其核心原理可以总结为以下几点:

1. 微任务队列

  • Vue 使用微任务(Microtask)来实现异步执行。在执行 Vue 的响应式更新时,更新将被推入一个任务队列,使用 Promise.resolve().then() 来确保这些任务在 DOM 更新后执行。
  • 这种机制的好处是可以将 DOM 更新和回调函数的执行分开,确保所有数据变更得到处理后,再进行下一步操作。

2. 任务合并

  • Vue 中的调度器将多个任务合并为一个更新批次,从而减少了对 DOM 的多次操作,提高性能。
  • 当数据变化时,会把任务加入一个队列,待所有任务处理完后,再统一执行相关的回调。

3. 递归处理

  • 为了防止由于任务重复触发导致的无限递归,Vue 在任务执行时会检查执行次数,防止超过设定的最大递归限制(如 100 次)。
  • 在执行某个任务时,如果该任务又引发了自己的重复执行,Vue 会记录并限制该任务的执行,以免造成栈溢出。

4. 状态标志

  • 每个任务都被标记(使用标志位),如 QUEUED(已入队)、ALLOW_RECURSE(允许递归)等,以便管理任务的状态和执行条件。
  • 通过这些标志位,调度器可以灵活控制任务的执行顺序和执行条件。

5. 前置和后置回调

  • Vue 的调度器还支持前置和后置回调,允许在更新操作的特定阶段执行自定义逻辑。
  • 前置回调会在真正更新前被执行,而后置回调则是在更新后被执行,这样可以满足不同场景中的需要。
// 源码位置 packages\runtime-core\src\scheduler.ts
export enum SchedulerJobFlags {
  QUEUED = 1 << 0,          // 表示任务已被加入队列
  PRE = 1 << 1,            // 表示前置任务
  ALLOW_RECURSE = 1 << 2,  // 允许递归调用
  DISPOSED = 1 << 3,       // 表示任务已被处置
}

// 定义调度任务的接口
export interface SchedulerJob extends Function {
  id?: number              // 唯一标识符
  flags?: SchedulerJobFlags // 任务状态标志
  i?: ComponentInternalInstance // 组件实例信息
}

const queue: SchedulerJob[] = [] // 任务队列
let flushIndex = -1 // 当前刷新的索引

const pendingPostFlushCbs: SchedulerJob[] = [] // 待处理的后置回调
let activePostFlushCbs: SchedulerJob[] | null = null // 当前活动的后置回调队列
let postFlushIndex = 0 // 后置回调索引

const resolvedPromise = Promise.resolve() as Promise<any> // 已解决的 Promise
let currentFlushPromise: Promise<void> | null = null // 当前刷新 Promise

const RECURSION_LIMIT = 100 // 最大递归限制
type CountMap = Map<SchedulerJob, number> // 用于记录递归调用次数的映射

// nextTick 方法,用于在 DOM 更新后执行回调
export function nextTick<T = void, R = void>(
  this: T,
  fn?: (this: T) => R,
): Promise<Awaited<R>> {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

// 查找合适的插入位置,以保持任务队列的顺序
function findInsertionIndex(id: number) {
  let start = flushIndex + 1
  let end = queue.length

  while (start < end) {
    const middle = (start + end) >>> 1
    const middleJob = queue[middle]
    const middleJobId = getId(middleJob)
    if (
      middleJobId < id ||
      (middleJobId === id && middleJob.flags! & SchedulerJobFlags.PRE)
    ) {
      start = middle + 1
    } else {
      end = middle
    }
  }
  return start
}

// 将任务加入队列
export function queueJob(job: SchedulerJob): void {
  if (!(job.flags! & SchedulerJobFlags.QUEUED)) {
    const jobId = getId(job)
    const lastJob = queue[queue.length - 1]
    if (
      !lastJob ||
      (!(job.flags! & SchedulerJobFlags.PRE) && jobId >= getId(lastJob))
    ) {
      queue.push(job) // 快速路径
    } else {
      queue.splice(findInsertionIndex(jobId), 0, job) // 按顺序插入
    }

    job.flags! |= SchedulerJobFlags.QUEUED // 标记为已加入队列
    queueFlush() // 刷新队列
  }
}

// 控制队列的刷新
function queueFlush() {
  if (!currentFlushPromise) {
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

// 加入后置回调
export function queuePostFlushCb(cb: SchedulerJobs): void {
  if (!isArray(cb)) {
    if (activePostFlushCbs && cb.id === -1) {
      activePostFlushCbs.splice(postFlushIndex + 1, 0, cb)
    } else if (!(cb.flags! & SchedulerJobFlags.QUEUED)) {
      pendingPostFlushCbs.push(cb)
      cb.flags! |= SchedulerJobFlags.QUEUED
    }
  } else {
    pendingPostFlushCbs.push(...cb)
  }
  queueFlush()
}

// 刷新所有前置回调
export function flushPreFlushCbs(
  instance?: ComponentInternalInstance,
  seen?: CountMap,
  i: number = flushIndex + 1,
): void {
  if (__DEV__) {
    seen = seen || new Map()
  }
  for (; i < queue.length; i++) {
    const cb = queue[i]
    if (cb && cb.flags! & SchedulerJobFlags.PRE) {
      if (instance && cb.id !== instance.uid) {
        continue
      }
      if (__DEV__ && checkRecursiveUpdates(seen!, cb)) {
        continue
      }
      queue.splice(i, 1)
      i--
      if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
        cb.flags! &= ~SchedulerJobFlags.QUEUED
      }
      cb()
      if (!(cb.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
        cb.flags! &= ~SchedulerJobFlags.QUEUED
      }
    }
  }
}

// 刷新所有后置回调
export function flushPostFlushCbs(seen?: CountMap): void {
  if (pendingPostFlushCbs.length) {
    const deduped = [...new Set(pendingPostFlushCbs)].sort(
      (a, b) => getId(a) - getId(b),
    )
    pendingPostFlushCbs.length = 0
    if (activePostFlushCbs) {
      activePostFlushCbs.push(...deduped)
      return
    }

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

    for (
      postFlushIndex = 0;
      postFlushIndex < activePostFlushCbs.length;
      postFlushIndex++
    ) {
      const cb = activePostFlushCbs[postFlushIndex]
      if (__DEV__ && checkRecursiveUpdates(seen!, cb)) {
        continue
      }
      if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
        cb.flags! &= ~SchedulerJobFlags.QUEUED
      }
      if (!(cb.flags! & SchedulerJobFlags.DISPOSED)) cb()
      cb.flags! &= ~SchedulerJobFlags.QUEUED
    }
    activePostFlushCbs = null
    postFlushIndex = 0
  }
}

// 获取任务 ID
const getId = (job: SchedulerJob): number =>
  job.id == null ? (job.flags! & SchedulerJobFlags.PRE ? -1 : Infinity) : job.id

// 执行所有任务
function flushJobs(seen?: CountMap) {
  if (__DEV__) {
    seen = seen || new Map()
  }

  const check = __DEV__ ? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job) : NOOP

  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job && !(job.flags! & SchedulerJobFlags.DISPOSED)) {
        if (__DEV__ && check(job)) {
          continue
        }
        if (job.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
          job.flags! &= ~SchedulerJobFlags.QUEUED
        }
        callWithErrorHandling(job, job.i, job.i ? ErrorCodes.COMPONENT_UPDATE : ErrorCodes.SCHEDULER)
        if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
          job.flags! &= ~SchedulerJobFlags.QUEUED
        }
      }
    }
  } finally {
    for (; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job) {
        job.flags! &= ~SchedulerJobFlags.QUEUED
      }
    }

    flushIndex = -1
    queue.length = 0

    flushPostFlushCbs(seen)

    currentFlushPromise = null
    if (queue.length || pendingPostFlushCbs.length) {
      flushJobs(seen)
    }
  }
}

// 检查是否出现递归更新
function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) {
  const count = seen.get(fn) || 0
  if (count > RECURSION_LIMIT) {
    const instance = fn.i
    const componentName = instance && getComponentName(instance.type)
    handleError(
      `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.`,
      null,
      ErrorCodes.APP_ERROR_HANDLER,
    )
    return true
  }
  seen.set(fn, count + 1)
  return false
}

vue2 和 vue3实现nextTick 的差异

1. 内部实现机制

  • Vue 2

    • 使用微任务队列管理异步回调。Vue 2 在实现中会依赖 PromiseMutationObserver,并在没有这两者时回退到 setTimeout
    • nextTick 是通过一个默认的回调队列来执行,并确保在 DOM 更新后执行用户提供的回调。
  • Vue 3

    • 采用了更简化的实现策略,依然使用微任务,但引入了更强大的响应式系统和更高效的调度机制。
    • Vue 3 的 nextTick 可以直接使用 Promise.resolve() 作为主要的微任务实现,简化了代码。

2. API 的一致性与返回值

  • Vue 2

    • 支持通过回调函数或返回 Promise 的方式来使用 nextTick
    • 使用 nextTick 时可以提供一个回调函数,也可以不提供,若不提供则会返回一个 Promise。
  • Vue 3

    • 在 API 方面更为一致,nextTick 的行为改善了,保持了相同的使用方式,但返回值对 Promise 的处理更加明确。
    • 直接返回 Promise,允许使用 async/await 的语法,使得在处理副作用时更为自然。

3. 性能优化

  • Vue 2

    • 因为使用了多种方法从回调队列中提取微任务,并且在多次调用 nextTick 时,可能导致响应时间的延迟。
  • Vue 3

    • 通过重构响应式系统和调度机制,获得了更高效的更新策略。Vue 3 的 nextTick 性能层面得到了优化,可以更快地处理多个异步任务。

4. 新的响应式架构

  • Vue 3 中引入了全新的响应式 API(基于 Proxy),这影响了 nextTick 的应用场景。在 Vue 3 中,组件的响应式更新更加高效,因此在使用 nextTick 时可以更好地控制更新时机,和提高整体性能。