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
源码注释列出排序的三个目的:
- 组件从父到子更新,因为父组件总是先于子组件创建,
id更小。 - 组件的用户 watcher(
watch选项、$watch)先于渲染 watcher 执行,因为它们创建得更早。 - 父组件的 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
// 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 安排刷新。