前端知库

Vue 2 的异步更新队列和 nextTick 降级策略是怎样实现的?

Vue 2 把触发的 watcher 按 id 去重放入队列,在 nextTick 中执行 flushSchedulerQueue,按 id 排序保证父先于子、用户 watcher 先于渲染 watcher。nextTick 依次尝试 Promise、MutationObserver、setImmediate、setTimeout。

参考答案

Vue 2 已于 2023-12-31 停止维护,本题以 Vue 2.7(vuejs/vue main 分支)源码为准。nextTick 的日常用法见《修改状态后,为什么有时需要 await nextTick()?》。

简答

  • 数据变化时,依赖它的 watcher 调用 queueWatcher 入队;同一个 watcher 按 id 去重,一轮中只入队一次。
  • 第一次入队时用 nextTick(flushSchedulerQueue) 安排刷新;刷新前按 id 升序排序(带 post 标记的排在最后),保证父组件先于子组件、用户 watcher 先于渲染 watcher。
  • nextTick 把回调存进 callbacks 数组,同一轮只调用一次 timerFunc,统一执行。
  • timerFunc 的降级顺序:原生 Promise.then → MutationObserver → setImmediate → setTimeout(fn, 0),前两者是微任务。
  • nextTick 回调与 flushSchedulerQueue 在同一个 callbacks 数组里按注册顺序执行,所以在修改数据之前注册的回调读不到新 DOM。

入队与去重:queueWatcher

queueWatcher 用 has[id] 记录已入队的 watcher:

  • 已在队列中直接返回,这就是官方文档说的“同一个 watcher 被多次触发,只会被推入队列一次”。
  • 不在刷新中时直接 push;正在刷新时,按 id 插入到合适位置,如果已经越过它的位置,就紧接着执行。
  • waiting 标记保证每轮只调用一次 nextTick(flushSchedulerQueue)。

刷新:flushSchedulerQueue

源码注释列出排序的三个目的:

  1. 组件从父到子更新,因为父组件总是先于子组件创建,id 更小。
  2. 组件的用户 watcher(watch 选项、$watch)先于渲染 watcher 执行,因为它们创建得更早。
  3. 父组件的 watcher 执行时销毁了子组件,可以跳过子组件的 watcher。

循环中不缓存 queue.length,因为执行过程中可能有新的 watcher 插入。执行每个 watcher 前先把 has[id] 置空;开发环境下如果同一个 watcher 再次入队超过 MAX_UPDATE_COUNT(100)次,会警告“You may have an infinite update loop”并中止。队列跑完后先调用 activated,再倒序调用组件的 updated 钩子。

nextTick 的降级策略

next-tick.ts 在模块加载时选定 timerFunc:

条件实现类型
原生 Promise 可用p.then(flushCallbacks),iOS 上额外 setTimeout(noop) 以绕过 UIWebView 的问题微任务
非 IE 且有原生 MutationObserver监听一个文本节点,修改其内容触发回调微任务
原生 setImmediate 可用setImmediate(flushCallbacks)宏任务
都不可用setTimeout(flushCallbacks, 0)宏任务

注释说明了历史:2.5 曾混用宏任务和微任务,因为在重绘前改状态(如 out-in 过渡)和事件处理中存在问题,之后改回全部使用微任务。代价是微任务优先级过高,可能插在连续事件甚至同一事件的冒泡之间执行。MutationObserver 排在 Promise 之后,是因为它在 iOS ≥ 9.3.3 的 UIWebView 中有问题,在 IE11 中也不可靠。

不传回调且环境支持 Promise 时,nextTick() 返回一个 Promise。

示例:回调注册顺序决定能否读到新 DOM

JavaScript
// vue@2.7.16,在浏览器中运行
const vm = new Vue({ data: { n: 0 }, template: '<p>{{ n }}</p>' }).$mount();
const out = [];

vm.$nextTick(() => out.push('先注册: ' + vm.$el.textContent));
vm.n = 1; // flushSchedulerQueue 排在上一个回调之后
vm.$nextTick(() => out.push('后注册: ' + vm.$el.textContent));
Promise.resolve().then(() => out.push('Promise.then'));
out.push('同步: ' + vm.$el.textContent);

setTimeout(() => console.log(out));
// ['同步: 0', '先注册: 0', '后注册: 1', 'Promise.then']

第一次调用 $nextTick 时 timerFunc 已经用 Promise.resolve().then 安排了 flushCallbacks,后面的 Promise.then 排在它之后,所以最后执行。

常见追问

  • 为什么不每次修改都同步渲染?同一事件中的多次修改会合并成一次渲染,避免重复计算和 DOM 操作。
  • 测试中想同步更新怎么办?源码中开发环境 config.async = false 时直接同步执行 flushSchedulerQueue,这是给测试工具用的,不应在业务中使用。
  • Vue 3 有什么不同?Vue 3 不再做降级,直接基于 Promise 安排刷新。