Vue 3源码极客之:`Vue`的`nextTick`:`nextTick`的内部实现:`Promise`、`MutationObserver`和`setTimeout`的降级。

咳咳,各位靓仔靓女们,晚上好!我是今晚的讲师,咱们今天聊聊 Vue 3 源码里一个挺有意思的家伙:nextTick。这玩意儿你可能天天用,但深挖一下,会发现它是个“老司机”,根据浏览器环境,灵活切换不同的“座驾”,保证你的代码在合适的时机执行。

今天咱们就来扒一扒 nextTick 的底裤,看看它是怎么在 PromiseMutationObserversetTimeout 之间优雅降级的。

一、nextTick 是个啥?

简单来说,nextTick 允许你将回调函数延迟到 DOM 更新周期之后执行。 啥意思?

想象一下,你在 Vue 组件里修改了一个数据,比如:

<template>
  <div>
    <p ref="myParagraph">{{ message }}</p>
  </div>
</template>

<script>
import { ref, nextTick, onMounted } from 'vue';

export default {
  setup() {
    const message = ref('Hello');
    const myParagraph = ref(null);

    onMounted(() => {
      message.value = 'World';

      // 立即获取 DOM 元素的内容
      console.log('立即获取:', myParagraph.value.textContent); // 可能还是 "Hello"

      nextTick(() => {
        // 在 DOM 更新后获取 DOM 元素的内容
        console.log('nextTick 获取:', myParagraph.value.textContent); // "World"
      });
    });

    return {
      message,
      myParagraph
    };
  }
};
</script>

在这个例子里,如果你直接在 message.value = 'World' 之后去获取 myParagraph.value.textContent,很可能你拿到的还是旧值 "Hello"。 因为 Vue 的 DOM 更新是异步的。

nextTick 就像一个承诺,它保证你提供的回调函数一定会在 DOM 更新完成之后执行。 这样,你就能拿到最新的 DOM 状态。

二、nextTick 的内部实现:降级策略

Vue 3 的 nextTick 是个聪明蛋,它会根据当前环境的支持情况,选择不同的方式来实现延迟执行:

  1. 首选:Promise.resolve().then(callback)

    如果浏览器支持 PromisenextTick 优先使用 Promise.resolve().then(callback)。 这是最理想的情况,因为 Promise.then 的回调函数会在当前事件循环的末尾执行,也就是在 DOM 更新之后。

    // 简化版模拟
    function nextTickWithPromise(callback) {
      Promise.resolve().then(callback);
    }

    这种方式的优点是性能好,而且标准可靠。

  2. 备选:MutationObserver

    如果浏览器不支持 PromisenextTick 会尝试使用 MutationObserverMutationObserver 是一种监听 DOM 变化的 API。 Vue 利用它来监听一个文本节点的 textContent 变化,一旦发生变化,就执行回调函数。

    // 简化版模拟
    function nextTickWithMutationObserver(callback) {
      let observer = new MutationObserver(callback);
      let textNode = document.createTextNode(String(0)); // 需要先创建一个文本节点
      observer.observe(textNode, {
        characterData: true
      });
      textNode.data = String(Number(textNode.data) + 1); // 触发 MutationObserver
    }

    这种方式的优点是可以在 DOM 变化时立即执行回调,缺点是需要创建和维护一个 MutationObserver 实例,有一定的开销。 而且兼容性不如 Promise

  3. 最终方案:setTimeout(callback, 0)

    如果浏览器既不支持 Promise,也不支持 MutationObservernextTick 就只能祭出 setTimeout(callback, 0) 这个大杀器了。 setTimeout 会将回调函数添加到浏览器的任务队列中,等待下一个事件循环执行。

    // 简化版模拟
    function nextTickWithSetTimeout(callback) {
      setTimeout(callback, 0);
    }

    这种方式的优点是兼容性好,几乎所有浏览器都支持。 缺点是延迟时间不可控,可能会比 PromiseMutationObserver 慢。

三、代码剖析:Vue 3 源码中的 nextTick

接下来,咱们来扒一下 Vue 3 源码中 nextTick 的实现(简化版,只保留核心逻辑):

// packages/runtime-core/src/scheduler.ts

const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>

let currentFlushPromise: Promise<any> | null = null

const queue: (Job | null)[] = []
let flushIndex = 0

const p = Promise.resolve()

let isFlushing = false
let isFlushPending = false

const RECURSION_LIMIT = 100
type CountMap = Map<SchedulerJob, number>

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

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before its child)
  // 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!))

  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job) {
        if (__DEV__) {
          checkRecursiveUpdates(seen!, job)
        }
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    flushIndex = 0
    queue.length = 0
    isFlushing = false
    currentFlushPromise = null
    // some post-flush things may have queued new jobs, so recursively flush them
    // also, if there is a pending error, it is possible that more jobs have been
    // queued, so keep flushing until the error is taken care of.
    if (queue.length || pendingPostFlushCbs.length) {
      flushPostFlushCbs(seen)
      flushJobs(seen)
    }
  }
}

let postFlushCbs: Function[] | null = null
const pendingPostFlushCbs: Function[] = []

function queuePostFlushCb(cb: Function | Function[]) {
  if (!isArray(cb)) {
    pendingPostFlushCbs.push(cb)
  } else {
    // if cb is an array, it is a component lifecycle hook which may contain
    // multiple jobs. This is only possible when using compiler-dom (e.g. in the browser).
    pendingPostFlushCbs.push(...cb)
  }
  queueFlush()
}

const sequenceJob = (job: Function | Function[]): Function[] => {
    if (isArray(job)) {
        return job
    }
    return [job]
}

const nextTick = ((
  fn?: () => void
): Promise<void> => {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this.sequenceJob(fn)) : p
}) as NextTick

let isUsingMicrotask = false

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

const invalidateJob = (job:SchedulerJob) => {
    const i = queue.indexOf(job)
    if (i > -1) {
        queue[i] = null
    }
}

export {
  nextTick,
  queuePostFlushCb,
  invalidateJob,
  sequenceJob
}

这段代码里,我们可以看到:

  • resolvedPromise:一个已经 resolve 的 Promise 实例,用于创建微任务。
  • nextTick 函数:它接收一个回调函数 fn 作为参数,然后将 fn 包装成一个 Promise.then 的回调函数,利用 Promise 实现异步执行。
  • queueFlush 函数:它负责将 flushJobs 函数(用于执行所有待处理的任务)添加到任务队列中。 queueFlush 函数内部会判断当前是否正在刷新队列,如果不是,则创建一个 Promise 实例,并将 flushJobs 函数添加到 Promise.then 的回调中。

重点:

这里直接使用了 Promise.resolve().then(flushJobs),而没有看到 MutationObserversetTimeout 的身影。 这是因为 Vue 3 在初始化的时候,会检测浏览器是否支持 PromiseMutationObserver,如果不支持,就会使用 setTimeout 来模拟异步任务。这个检测过程在 runtime-core 模块的 createApp 函数中(或者更底层的初始化函数中)。

四、降级策略的实现细节

虽然源码中没有直接看到 MutationObserversetTimeout 的代码,但 Vue 在初始化的时候,会根据环境选择不同的 nextTick 实现。 这个选择过程通常发生在 runtime-core 或者更底层的模块的初始化阶段。

// 伪代码,模拟环境检测和 nextTick 的初始化
let nextTick;

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // 使用 Promise
  nextTick = (fn) => {
    Promise.resolve().then(fn);
  };
} else if (
  typeof MutationObserver !== 'undefined' &&
  (isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
  // 使用 MutationObserver
  let timerFunc;
  let observer = new MutationObserver(function () {
    timerFunc();
  });
  let textNode = document.createTextNode(String(1));
  observer.observe(textNode, {
    characterData: true
  });
  timerFunc = () => {
      // some logic to call the callbacks
  }
  nextTick = (fn) => {
      // some logic to push the callback into queue
      textNode.data = String(Number(textNode.data) + 1)
  }
} else {
  // 使用 setTimeout
  nextTick = (fn) => {
    setTimeout(fn, 0);
  };
}

// Vue 内部使用 nextTick
export { nextTick };

这段伪代码展示了 Vue 如何根据浏览器环境选择不同的 nextTick 实现。

五、nextTick 的应用场景

  • 在 DOM 更新后获取最新的 DOM 状态: 这是 nextTick 最常见的应用场景。
  • 在组件渲染完成后执行某些操作: 比如,初始化第三方库、 focus 输入框等。
  • 解决异步更新导致的问题: 有些时候,连续多次修改数据可能会导致 DOM 更新不正确,可以使用 nextTick 来确保 DOM 更新的顺序。

六、nextTick 的注意事项

  • 不要过度使用 nextTick 频繁使用 nextTick 可能会影响性能。 尽量避免在不必要的情况下使用 nextTick
  • 理解 nextTick 的执行时机: nextTick 的回调函数会在 DOM 更新之后、下一个事件循环之前执行。 这意味着,你仍然需要在 nextTick 内部处理一些异步操作。
  • 注意 this 的指向:nextTick 的回调函数中,this 指向的是 Vue 实例。 如果你需要访问其他上下文,可以使用箭头函数或者 bind 方法。

七、总结

技术方案 优点 缺点 兼容性
Promise 性能好,标准可靠 兼容性不如 MutationObserversetTimeout 现代浏览器
MutationObserver 可以在 DOM 变化时立即执行回调 需要创建和维护实例,开销较大,兼容性稍差 较新的浏览器
setTimeout 兼容性好,几乎所有浏览器都支持 延迟时间不可控,可能较慢 所有浏览器

nextTick 是 Vue 异步更新策略的重要组成部分。 它通过降级策略,保证了在各种浏览器环境下都能正常工作。 理解 nextTick 的内部实现,可以帮助你更好地理解 Vue 的渲染机制,并写出更高效的代码。

好了,今天的分享就到这里。 希望大家有所收获。 如果有什么问题,欢迎提问。

下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注