前端 Vue 专栏 06:异步更新机制、任务队列与 nextTick
前言
上一篇文章讲完 Vue 响应式原理后,我们已经知道:
text
读取响应式数据时,track 收集依赖;
修改响应式数据时,trigger 通知依赖。
但是还有一个问题没有解决。
假设组件中有:
vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
console.log('状态:', count.value)
console.log('DOM:', document.querySelector('#count').textContent)
}
</script>
<template>
<button id="count" @click="increment">
{{ count }}
</button>
</template>
点击以后可能输出:
text
状态:1
DOM:0
为什么响应式状态已经变成 1,DOM 还显示 0?
再看:
js
count.value++
count.value++
count.value++
为什么状态最终变成 3,组件通常只更新一次,而不是连续操作三次 DOM?
这些问题都指向 Vue 响应式系统与渲染系统中间的一层:
text
调度器 scheduler
这一篇将沿着下面的主线展开:
text
响应式状态变化
↓
trigger 找到组件 effect
↓
scheduler 把更新任务放入队列
↓
同一任务去重和排序
↓
通过微任务刷新队列
↓
组件重新渲染并更新 DOM
↓
nextTick 等待这次刷新完成
本文将重点讲清楚:
- JavaScript 同步代码、宏任务和微任务;
- Promise、
setTimeout与async/await的执行顺序; - Vue 为什么异步更新 DOM;
- scheduler 和组件更新队列;
- 任务怎样去重、排序和刷新;
nextTick到底等待什么;watch的flush: 'pre' | 'post' | 'sync';- 父子组件更新与生命周期顺序;
- Vue 2 和 Vue 3 异步更新机制的联系;
- 高频面试题与项目场景。
一、先区分状态更新和 DOM 更新
执行:
js
count.value++
这里包含两个不同的变化。
1. JavaScript 状态变化
js
console.log(count.value)
会立即得到新值。
状态修改本身是同步的:
text
0 → 1
2. 组件 DOM 更新
模板中虽然使用了:
vue
{{ count }}
但 Vue 不会在 setter 执行的同一行代码中立刻完成组件重新渲染和 DOM 修改。
它通常会把组件更新任务放进队列,等待当前同步代码执行完后统一刷新。
因此:
text
响应式状态:已经是新值;
页面 DOM:可能还是旧内容。
一定不要把它说成:
text
Vue 的数据是异步修改的。
更准确的表达是:
Vue 响应式状态通常同步改变,但由状态触发的组件渲染和 DOM 更新会经过调度器批量处理。
二、Vue 为什么不立即更新 DOM
假设 Vue 每次状态变化都立刻更新组件:
js
count.value++
count.value++
count.value++
可能会发生:
text
count = 1 → 重新渲染并修改 DOM;
count = 2 → 再次重新渲染并修改 DOM;
count = 3 → 第三次重新渲染并修改 DOM。
但是前两个中间状态通常不会被用户看到。
真正需要提交到页面的往往只是:
text
count = 3
所以 Vue 更合理的做法是:
text
第一次变化 → 把组件更新任务加入队列;
第二次变化 → 发现同一个任务已经在队列中,不重复加入;
第三次变化 → 仍然不重复加入;
当前同步代码结束 → 执行一次组件更新,读取最终状态 3。
这样可以减少:
- 重复执行组件渲染函数;
- 重复创建和比较 VNode;
- 重复操作真实 DOM;
- 同一轮业务逻辑中不必要的中间更新。
因此异步更新并不是为了让语法显得复杂,而是为了批处理和去重。
三、理解 Vue 调度前,先理解 JavaScript 事件循环
JavaScript 在浏览器主线程上执行代码时,首先运行当前调用栈中的同步任务。
异步任务完成后,并不是随时插入正在执行的函数,而是进入相应队列,等待事件循环调度。
可以先建立一个简化模型:
text
执行一个任务中的同步代码
↓
调用栈清空
↓
清空本轮微任务队列
↓
浏览器可能进行样式计算、布局和绘制
↓
开始下一个任务
注意"可能进行渲染"中的"可能"。浏览器不会机械地在每个任务后都绘制一次,还会结合刷新时机和页面状态决定是否渲染。
四、什么是同步代码
普通语句会按照调用栈顺序直接执行:
js
console.log('A')
function run() {
console.log('B')
}
run()
console.log('C')
输出:
text
A
B
C
调用函数时会进入新的函数执行上下文,但它仍然属于当前同步任务。
new Promise() 的执行器也是同步执行的:
js
new Promise(resolve => {
console.log('executor')
resolve()
})
其中:
js
resolve => {
console.log('executor')
resolve()
}
会在创建 Promise 时立即执行。
五、什么是宏任务
"宏任务"是前端面试中的常见叫法,更贴近规范的表达通常是 task。
常见任务来源包括:
text
初始脚本执行;
setTimeout、setInterval 回调;
部分用户交互事件回调;
消息事件;
部分 I/O 回调。
例如:
js
console.log('start')
setTimeout(() => {
console.log('timer')
}, 0)
console.log('end')
输出:
text
start
end
timer
setTimeout(..., 0) 不是立即执行,而是达到最短等待条件后,其回调才有资格进入后续任务调度。
当前同步脚本必须先执行完。
六、什么是微任务
常见微任务包括:
text
Promise.then、catch、finally 回调;
queueMicrotask() 回调;
MutationObserver 回调;
async 函数在 await 之后的继续执行部分。
例如:
js
console.log('A')
Promise.resolve().then(() => {
console.log('B')
})
setTimeout(() => {
console.log('C')
}, 0)
console.log('D')
输出:
text
A
D
B
C
原因是:
text
A、D 是当前同步代码;
B 是当前任务结束后的微任务;
C 是后续定时器任务。
简化优先级可以记成:
text
当前同步代码
↓
清空微任务队列
↓
下一个宏任务
七、分析上一篇提到的 Promise 题
代码:
js
const promise = new Promise(resolve => {
console.log('executor')
setTimeout(() => {
console.log('timer')
resolve()
}, 0)
})
promise.then(() => {
console.log('then')
})
console.log('after')
输出:
text
executor
after
timer
then
分析:
text
new Promise 的 executor 同步执行 → executor;
setTimeout 注册后续任务;
then 此时只是登记回调,因为 Promise 仍为 pending;
继续执行同步代码 → after;
执行定时器任务 → timer;
resolve 使 Promise 完成,并安排 then 微任务;
定时器回调结束后清空微任务 → then。
关键点是:
text
resolve() 不会在当前这一行直接同步执行 then 回调。
它会使相关 Promise reaction 在微任务中执行。
八、async 和 await 怎样进入微任务
看下面的代码:
js
async function run() {
console.log('A')
await Promise.resolve()
console.log('B')
}
console.log('C')
run()
console.log('D')
输出:
text
C
A
D
B
可以先理解为:
text
await 之前的代码同步执行;
await 之后的继续执行部分进入 Promise 相关的微任务流程。
因此:
js
await nextTick()
不会阻塞整个浏览器线程。
它会暂停当前 async 函数后面的部分,等 nextTick() 返回的 Promise 完成后,再通过微任务继续执行。
九、浏览器渲染和微任务是什么关系
前端经常背:
text
同步代码 → 微任务 → 浏览器渲染 → 宏任务
这是一种便于做题的简化模型,但不要把它理解为浏览器每清空一次微任务就必然绘制一帧。
更准确地说:
text
当前任务执行结束;
微任务检查点清空微任务;
浏览器在合适的渲染机会更新页面;
继续处理后续任务。
这带来两个重要结论:
- 微任务太多、持续产生新微任务,也会阻塞渲染;
- Vue 完成 DOM 节点修改,不等于浏览器已经把新像素绘制到屏幕上。
后面讲 nextTick 时还会回到这个区别。
十、专栏 05 的同步 effect 有什么问题
上一篇为了展示原理,写过类似代码:
js
function trigger(target, key) {
const dep = getDep(target, key)
dep.forEach(effectFn => {
effectFn()
})
}
这会让 effect 同步重新执行。
假设组件更新函数是:
js
effect(() => {
const vnode = render()
patch(vnode)
})
连续修改三次数据,就可能同步执行三次渲染。
所以 effect 需要允许外部决定:
text
依赖变化后是立即执行,还是放进队列。
这就是 scheduler 的作用。
十一、scheduler 是什么
可以给 effect 增加调度器:
js
effect(componentUpdate, {
scheduler(effectRunner) {
queueJob(effectRunner)
}
})
当依赖变化时,trigger 不再只能写:
js
effectRunner()
而是判断:
js
if (effectRunner.scheduler) {
effectRunner.scheduler(effectRunner)
} else {
effectRunner()
}
因此:
text
trigger:说明这个 effect 已经需要更新;
scheduler:决定它何时以及以什么方式更新。
不同响应式订阅可以采用不同策略:
text
组件 effect → 加入组件更新队列;
computed → 标记缓存失效;
默认 watch → 加入刷新前任务;
post watch → 加入刷新后任务;
sync watch → 立即执行。
十二、先手写一个最小任务队列
使用 Set 保存任务,可以天然去重:
js
const jobQueue = new Set()
const resolvedPromise = Promise.resolve()
let isFlushPending = false
function queueJob(job) {
jobQueue.add(job)
if (!isFlushPending) {
isFlushPending = true
resolvedPromise.then(flushJobs)
}
}
function flushJobs() {
try {
jobQueue.forEach(job => job())
} finally {
jobQueue.clear()
isFlushPending = false
}
}
执行:
js
function updateComponent() {
console.log('组件更新')
}
queueJob(updateComponent)
queueJob(updateComponent)
queueJob(updateComponent)
console.log('同步代码结束')
输出:
text
同步代码结束
组件更新
同一个函数加入 Set 三次,最终只保留一份。
同时 Promise.resolve().then(flushJobs) 把刷新安排到微任务中,所以会先完成当前同步代码。
十三、为什么只安排一个刷新微任务
如果每次 queueJob 都执行:
js
Promise.resolve().then(flushJobs)
连续入队三次,就会创建三个刷新微任务。
所以需要:
js
let isFlushPending = false
第一次入队:
text
isFlushPending 是 false;
创建刷新微任务;
改成 true。
后续继续入队:
text
发现刷新已经被安排;
只加入任务,不再创建新的刷新微任务。
刷新结束后再恢复:
js
isFlushPending = false
这样下一轮任务才能重新安排刷新。
十四、组件连续修改三次的完整过程
假设:
vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
function changeCount() {
count.value++
count.value++
count.value++
}
</script>
<template>
<button @click="changeCount">
{{ count }}
</button>
</template>
过程可以理解为:
text
第一次 count++
→ trigger 组件 effect
→ scheduler 将组件任务入队
→ 安排刷新微任务
第二次 count++
→ 再次 trigger 同一组件 effect
→ 任务已经入队,不重复加入
第三次 count++
→ 仍然是同一个任务
→ 不重复加入
同步代码结束
→ 执行刷新微任务
→ 组件任务执行一次
→ render 读取 count 最终值 3
→ patch 更新 DOM
这里要区分两个次数:
text
响应式属性确实被修改了三次;
组件 DOM 更新任务通常只执行一次。
十五、为什么任务队列还需要排序
只用 Set 去重,还没有处理父子组件顺序。
假设父组件创建在前,子组件创建在后。父组件更新时可能:
text
给子组件传递新的 props;
根据 v-if 卸载子组件;
改变子组件是否需要继续更新。
因此组件更新通常需要满足:
text
父组件更新任务先于子组件更新任务。
Vue 会为组件更新任务关联可以排序的标识。父组件先创建,通常拥有更小的组件 uid,调度队列按任务 id 保持合适顺序。
这样做还有一个重要作用:
text
如果父组件更新时卸载了子组件,队列中对应的子组件更新可以被跳过。
当前 Vue 3 调度器会在入队时根据任务 id 寻找插入位置,而不是把"所有任务最后统一 sort"当作唯一实现方式。源码细节可能继续优化,但父先子后和可跳过失效任务是理解重点。
十六、真实 Vue 3 调度器包含什么
当前 Vue 3 的运行时调度器包含的核心概念包括:
text
主任务队列 queue;
当前刷新位置 flushIndex;
后置回调队列 pendingPostFlushCbs;
当前刷新 Promise currentFlushPromise;
任务状态标记,例如 QUEUED、PRE、DISPOSED;
任务排序、去重和递归更新保护。
可以抽象成:
text
queueJob(job)
↓
检查是否已经入队
↓
根据 id 放入合适位置
↓
queueFlush()
↓
Promise.resolve().then(flushJobs)
↓
执行刷新前任务和组件任务
↓
执行刷新后回调
真实源码比教学版复杂,是因为它还要处理:
- 刷新过程中又产生新任务;
- 父子组件顺序;
- 组件已经卸载;
- watch 回调允许的递归更新;
- 生命周期钩子;
- Suspense 缓冲的副作用;
- 错误处理;
- 防止无穷递归更新。
不要把"任务队列就是一个 Set"当成 Vue 当前源码的精确实现。Set 版本只是为了先理解批处理和去重。
十七、一份包含 nextTick 的简化调度器
我们可以在最小队列基础上保存当前刷新 Promise:
js
const jobQueue = new Set()
const resolvedPromise = Promise.resolve()
let isFlushPending = false
let currentFlushPromise = null
function queueJob(job) {
jobQueue.add(job)
if (!isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
function flushJobs() {
try {
jobQueue.forEach(job => job())
} finally {
jobQueue.clear()
isFlushPending = false
currentFlushPromise = null
}
}
function nextTick(callback) {
const promise = currentFlushPromise || resolvedPromise
return callback
? promise.then(callback)
: promise
}
这里最关键的是:
js
const promise = currentFlushPromise || resolvedPromise
如果当前已经安排了 Vue 队列刷新:
text
nextTick 等待 currentFlushPromise;
也就是等待这轮 flushJobs 完成。
如果当前没有待刷新的 Vue 任务:
text
nextTick 使用已经完成的 Promise;
传入回调时,回调仍会作为 Promise 微任务执行。
十八、nextTick 到底是什么
Vue 官方对 nextTick 的定位是:
text
等待下一次 DOM 更新刷新完成的工具方法。
使用方式有两种。
1. 使用 await
js
import { nextTick } from 'vue'
async function increment() {
count.value++
console.log(element.textContent) // 旧 DOM
await nextTick()
console.log(element.textContent) // 更新后的 DOM
}
2. 传入回调
js
count.value++
nextTick(() => {
console.log(element.textContent)
})
现代项目中,涉及多步异步流程时,await nextTick() 通常更加清晰。
十九、nextTick 不是简单的延时器
很多人把它理解成:
js
setTimeout(callback, 0)
这是不准确的。
setTimeout 的含义是:
text
安排一个后续计时器任务。
nextTick 的语义是:
text
等待 Vue 当前已经安排的更新刷新完成。
在当前 Vue 3 调度器中,可以看到近似关系:
js
function nextTick(fn) {
const promise = currentFlushPromise || resolvedPromise
return fn ? promise.then(fn) : promise
}
所以 nextTick 和 Vue 自己的刷新 Promise 发生关联,而不是随便等一个固定时间。
二十、nextTick 和 Promise.resolve 一样吗
不能简单画等号。
两者都可能利用 Promise 微任务,但等待目标不同。
js
Promise.resolve().then(callback)
表示把 callback 接到一个已完成 Promise 后面。
而:
js
nextTick(callback)
如果 Vue 当前正在等待刷新,它会把回调接到 currentFlushPromise 后面。
因此它表达的是:
text
先等 Vue 当前刷新完成,再执行 callback。
不要依靠"它们都是微任务"就认为任意 Promise.resolve() 都能稳定替代 nextTick()。
二十一、nextTick 等待的是状态变化吗
不是。
状态在赋值时已经变化:
js
count.value++
console.log(count.value) // 已经是新值
nextTick 主要等待的是:
text
由这些状态变化触发的 Vue 更新队列完成刷新。
因此:
js
await nextTick()
并不是让 count.value 过一会儿才变化,而是等待组件把新状态反映到 DOM。
二十二、nextTick 会等待网络请求吗
不会。
js
async function loadUser() {
fetchUser()
await nextTick()
}
这里没有 await fetchUser(),nextTick 不会替你等待网络响应。
正确关系通常是:
js
async function loadUser() {
const user = await fetchUser()
currentUser.value = user
await nextTick()
// 此时等待的是 currentUser 引发的 Vue DOM 更新
}
需要等待什么,就应该等待对应的 Promise:
text
等待请求 → await fetch...;
等待 Vue DOM 更新 → await nextTick();
等待动画下一帧 → requestAnimationFrame();
等待一段时间 → setTimeout()。
二十三、nextTick 能保证浏览器已经绘制吗
不能把它绝对理解为"新像素已经出现在屏幕上"。
nextTick 主要保证 Vue 已经完成当前更新队列中的 DOM 提交。
而浏览器真正显示一帧还可能经历:
text
样式计算;
布局;
绘制;
合成。
如果业务只需要读取 Vue 更新后的 DOM 内容或节点,一般使用 nextTick。
如果明确需要等待浏览器进入后续绘制帧,例如启动某些依赖帧边界的动画,可以结合:
js
await nextTick()
requestAnimationFrame(() => {
// 浏览器下一帧相关操作
})
所以两者解决的问题不同:
text
nextTick:等待 Vue 更新队列;
requestAnimationFrame:把回调安排到浏览器下一次重绘前。
二十四、nextTick 的常见使用场景
1. v-if 创建元素后聚焦
vue
<script setup>
import { nextTick, ref } from 'vue'
const editing = ref(false)
const inputRef = ref()
async function startEdit() {
editing.value = true
await nextTick()
inputRef.value.focus()
}
</script>
<template>
<button @click="startEdit">编辑</button>
<input v-if="editing" ref="inputRef" />
</template>
赋值后立即访问:
js
inputRef.value.focus()
可能失败,因为 v-if 对应的输入框还没有挂载。
2. 列表更新后滚动到底部
js
async function addMessage(message) {
messages.value.push(message)
await nextTick()
container.value.scrollTop = container.value.scrollHeight
}
必须先让新消息节点进入 DOM,才能读取新的 scrollHeight。
3. DOM 更新后测量尺寸
js
expanded.value = true
await nextTick()
const height = panel.value.getBoundingClientRect().height
4. 与依赖 DOM 的第三方库同步
js
chartData.value = nextData
await nextTick()
chart.resize()
但如果第三方库本身有明确的更新 API,应该优先理解它的数据流,不要把所有同步问题都用 nextTick 掩盖。
二十五、什么时候不需要 nextTick
1. 只读取新的状态值
js
count.value++
console.log(count.value)
不需要等待,因为状态已经同步变化。
2. 普通业务计算
js
price.value = 100
quantity.value = 2
const total = price.value * quantity.value
这和 DOM 无关,不需要 nextTick。
3. 等待网络请求
应该:
js
const result = await fetchData()
而不是:
js
await nextTick()
4. 仅仅觉得执行顺序"不对"
不要先到处添加:
js
await nextTick()
应该先确认:
text
等待的是 Vue DOM 更新、网络、计时器,还是浏览器绘制?
二十六、watch 为什么也需要调度
状态变化时,可能同时触发:
text
组件更新 effect;
用户 watch 回调;
watchEffect;
computed 失效。
如果所有回调都立刻同步执行,会产生重复调用,也难以控制回调读取的是更新前还是更新后的 DOM。
因此 watch 提供:
js
flush: 'pre'
flush: 'post'
flush: 'sync'
它决定侦听器回调相对组件更新的执行时机。
二十七、flush: 'pre'
默认配置是:
js
watch(source, callback, {
flush: 'pre'
})
也可以省略:
js
watch(source, callback)
官方文档对默认侦听器时机的描述是:
text
如果存在父组件更新,在父组件更新之后;
在当前侦听器所属组件的 DOM 更新之前。
因此在默认 watch 回调中读取当前组件 DOM,通常还是更新前的内容。
js
watch(count, () => {
console.log(element.value.textContent)
})
如果目标是根据状态变化继续修改其他状态,默认 pre 通常很合适。
例如:
js
watch(keyword, () => {
page.value = 1
})
这些修改有机会在当前组件本轮渲染前被纳入更新结果。
二十八、flush: 'post'
如果侦听器需要读取所属组件更新后的 DOM,可以使用:
js
watch(count, () => {
console.log(element.value.textContent)
}, {
flush: 'post'
})
或者:
js
watchEffect(() => {
console.log(element.value?.textContent)
}, {
flush: 'post'
})
watchEffect 还有别名:
js
import { watchPostEffect } from 'vue'
watchPostEffect(() => {
console.log(element.value?.textContent)
})
可以把 post 理解为:
text
把侦听器回调放到组件 DOM 更新之后的后置队列。
适合:
- 读取更新后的 DOM;
- 测量元素;
- 操作依赖最新节点结构的第三方库;
- 某些需要在视图提交后执行的副作用。
二十九、flush: 'sync'
同步侦听:
js
watch(count, value => {
console.log('同步:', value)
}, {
flush: 'sync'
})
依赖发生变化时,回调会同步触发,不等待 Vue 的批处理队列。
例如:
js
count.value++
console.log('修改结束')
可能先输出:
text
同步:1
修改结束
watchEffect 对应别名是:
js
import { watchSyncEffect } from 'vue'
同步侦听器要谨慎使用,因为它不会进行常规批处理。
例如:
js
for (let i = 0; i < 1000; i++) {
list.value.push(i)
}
如果同步侦听这类频繁变化的数据,回调可能执行很多次。
适合 sync 的通常是简单、更新频率低并且确实要求同步响应的状态,不应该把它当成"更快的 watch"。
三十、三种 flush 怎样选择
| 配置 | 执行时机 | 是否通常批处理 | 常见用途 |
|---|---|---|---|
pre |
所属组件 DOM 更新前 | 是 | 根据变化执行逻辑、继续调整状态 |
post |
所属组件 DOM 更新后 | 是 | 读取或操作更新后的 DOM |
sync |
响应式依赖变化时同步执行 | 否 | 少量必须同步处理的简单状态 |
选择时先问:
text
回调是否需要访问更新后的 DOM?
如果不需要,通常保持默认 pre。
如果需要,使用 post 或在明确操作点 await nextTick()。
只有必须同步响应时才考虑 sync。
三十一、post watch 和 nextTick 有什么区别
两者都可能在组件 DOM 更新后执行,但使用语义不同。
post watch
js
watch(source, callback, {
flush: 'post'
})
表达:
text
以后每次 source 变化并完成组件更新后,都执行 callback。
nextTick
js
source.value = nextValue
await nextTick()
表达:
text
我在当前这次操作之后,等待本轮 Vue DOM 更新完成,再继续执行。
所以:
text
持续监听某个来源并在 DOM 后处理 → post watch;
某个具体方法中只等待这一次更新 → nextTick。
在常见刷新流程中,post-flush 回调属于 Vue 刷新过程的一部分,而 nextTick 回调通常接在当前刷新 Promise 完成之后,因此 post 回调通常早于随后等待的 nextTick 继续部分。
三十二、同一组件常见的更新阶段
对一个组件进行简化,可以先记成:
text
同步修改响应式状态
↓
sync watcher 同步触发
↓
当前同步代码执行完毕
↓
pre watcher
↓
组件重新渲染并更新 DOM
↓
post watcher / onUpdated
↓
nextTick 后续代码
这是一张帮助理解的阶段图,不应当脱离父子组件、任务创建顺序、嵌套更新等上下文,用来死猜所有复杂日志题。
真正分析代码时仍要确认:
text
是谁先入队;
属于哪个组件;
回调是哪种 flush;
是否在刷新过程中产生了新任务;
Promise 回调在哪一刻被注册。
三十三、父子组件为什么通常父组件先更新
假设父组件向子组件传递:
vue
<Child :count="count" />
父组件修改 count 后:
text
父组件需要重新渲染;
新的 count 作为 props 传给子组件;
子组件再根据新 props 更新。
所以组件更新任务通常按父到子执行。
当前 Vue 调度器的任务顺序设计也明确保障父组件任务优先,因为父组件通常先创建、任务 id 更小。
简化顺序:
text
父组件更新任务
↓
子组件更新任务
这不等于所有生命周期钩子都是父先子后。
三十四、为什么 updated 钩子通常子组件先执行
Vue 官方文档明确说明:
text
父组件的 updated 钩子在子组件 updated 钩子之后调用。
可以理解为:
text
先完成父组件更新过程;
处理子组件更新;
子组件确认更新完成;
最后父组件确认自己的整棵相关子树更新完成。
常见简化顺序是:
text
父 beforeUpdate
子 beforeUpdate
子 updated
父 updated
但实际是否触发某个组件更新,仍取决于它的依赖、props 和渲染结果,不能只靠背固定口诀分析所有情况。
三十五、为什么不要在 updated 中修改状态
js
onUpdated(() => {
count.value++
})
可能形成:
text
count 变化
→ 组件更新
→ onUpdated
→ 再修改 count
→ 再次组件更新
→ 再次 onUpdated
→ 无限循环
如果确实需要根据某个数据变化执行逻辑,通常应该明确使用:
text
computed;
watch;
事件处理函数;
一次性的初始化逻辑。
而不是在每次组件更新后无条件修改组件状态。
三十六、任务去重是不是会丢失数据变化
不会。
js
count.value = 1
count.value = 2
count.value = 3
每次赋值都真实修改了响应式状态,也都可能触发依赖通知。
去重的是:
text
同一个组件的更新任务。
任务最终执行时会读取最新状态:
js
render() {
return count.value // 3
}
因此中间值不会逐个反映到 DOM,但最终状态不会丢失。
三十七、刷新过程中又产生任务怎么办
组件更新期间可能继续触发响应式变化:
text
父组件更新 props;
子组件侦听 props 后修改自己的状态;
某个 watch 回调触发另一个响应式修改。
真实调度器需要允许新任务在刷新期间进入合适位置,并在当前刷新或后续刷新中继续处理。
因此不能简单实现成:
js
const jobs = [...queue]
queue.clear()
jobs.forEach(job => job())
然后完全忽略执行过程中出现的新任务。
Vue 的调度器会维护当前刷新索引、任务标记和不同阶段队列,确保新增任务被合理处理,同时避免同一任务无意义地递归触发自己。
三十八、为什么需要防止递归更新
下面的 watch 会不断修改自己的数据源:
js
watch(count, () => {
count.value++
})
过程是:
text
count 变化
→ watch 回调
→ count 再变化
→ watch 回调再次执行
→ 持续循环
框架可以检测异常的递归更新并给出警告,但根本问题仍然是业务逻辑没有收敛条件。
正确代码必须保证最终停止,例如:
js
watch(count, value => {
if (value > 10) {
count.value = 10
}
})
即使有防护,也不应该依赖框架的递归限制来设计业务流程。
三十九、Vue 2 也有异步更新队列吗
有。
Vue 2 使用 Object.defineProperty、Dep 和 Watcher 完成响应式,但 Watcher 收到通知后,同样通常不会每次都立即更新 DOM。
Vue 2 的整体链路可以概括为:
text
响应式 setter
↓
dep.notify()
↓
watcher.update()
↓
queueWatcher(watcher)
↓
同一 Watcher 去重
↓
nextTick(flushSchedulerQueue)
↓
统一运行 Watcher
Vue 2 官方文档同样强调:如果一个 Watcher 在同一事件循环中被多次触发,只会进入队列一次,以避免不必要的计算和 DOM 操作。
因此:
text
响应式拦截方式不同,不代表 Vue 2 没有异步批处理。
四十、Vue 2 和 Vue 3 调度机制怎样对照
| 环节 | Vue 2 | Vue 3 |
|---|---|---|
| 响应式订阅者 | Watcher | ReactiveEffect / scheduler job |
| 收到更新 | watcher.update() |
effect 的 scheduler |
| 加入队列 | queueWatcher() |
queueJob() 等调度入口 |
| 去重目标 | 同一个 Watcher | 同一个 job 的已入队状态 |
| 队列刷新 | flushSchedulerQueue() |
flushJobs() |
| 等待更新 | Vue.nextTick / vm.$nextTick |
nextTick / this.$nextTick |
两者核心目的相同:
text
把同一轮状态变化合并为尽量少的组件更新。
Vue 3 对任务阶段、组件 effect、pre/post 回调和调度标记进行了新的组织,但"通知后先入队,再统一刷新"的核心思想延续了下来。
四十一、一道 Vue 更新顺序题
假设组件更新已经被 count 修改触发:
js
console.log('start')
count.value++
nextTick(() => {
console.log('nextTick')
})
Promise.resolve().then(() => {
console.log('promise')
})
setTimeout(() => {
console.log('timer')
}, 0)
console.log('end')
在当前常见 Vue 3 调度路径下,仅观察这些日志,通常得到:
text
start
end
promise
nextTick
timer
为什么 promise 可能在 nextTick 前面?
count.value++ 先安排 Vue 的刷新 Promise。
nextTick 不是立即把自己的回调作为独立微任务塞入队列,而是把回调接到当前刷新 Promise 后面:
text
先完成 Vue flushJobs;
当前刷新 Promise 完成;
再安排 nextTick 回调。
而后面的:
js
Promise.resolve().then(() => {
console.log('promise')
})
会直接登记自己的微任务。
所以不能只背:
text
nextTick 是微任务,Promise.then 也是微任务,谁写前面谁先执行。
还要看回调连接的是哪个 Promise,以及这个 Promise什么时候完成。
如果把 Promise 和 nextTick 的注册顺序、状态修改位置改变,结果也可能改变。做题时应画出真实的 Promise 依赖关系。
四十二、加入 onUpdated 后怎样分析
js
onUpdated(() => {
console.log('updated')
})
async function increment() {
console.log('start')
count.value++
await nextTick()
console.log('after nextTick')
}
常见顺序:
text
start
updated
after nextTick
因为:
text
onUpdated 属于组件更新后的 Vue 刷新阶段;
await nextTick() 的后续代码等待本次刷新 Promise 完成。
但 onUpdated 会在组件的任意 DOM 更新后运行,而 nextTick 更适合等待某一次明确状态修改引起的 DOM 更新。
四十三、常见错误
1. 认为响应式数据异步赋值
错误理解:
text
count.value++ 后,count 还没有变。
正确理解:
text
count 已经同步变化,只是 DOM 更新被调度。
2. 用 setTimeout 替代 nextTick
js
setTimeout(() => {
// 猜测 Vue 应该更新完了
}, 0)
这种写法表达的是等待定时器任务,不是等待 Vue 当前刷新。
需要等待 Vue DOM 更新时应该使用:
js
await nextTick()
3. 到处使用 nextTick
如果大量业务代码都依赖 nextTick 才能工作,可能说明:
text
过度直接操作 DOM;
状态职责不清晰;
组件通信方向混乱;
把请求、动画和 Vue 更新混成了一件事。
4. 使用 sync watch 追踪频繁变化的大数组
同步侦听器不会进行常规批处理,可能导致大量重复回调。
5. 在 onUpdated 中无条件修改状态
这很容易造成递归更新。
6. 认为 nextTick 一定等待浏览器绘制完成
它等待的是 Vue 更新队列,不等同于显示器已经呈现新像素。
7. 认为任务去重会丢失最终数据
去重的是更新任务,组件执行时读取的是最新响应式状态。
8. 只背宏任务和微任务优先级
复杂题还要分析:
text
Promise 在什么时候完成;
then 在什么时候注册;
微任务执行中是否创建了新微任务;
Vue 更新任务在什么时候入队。
四十四、项目中怎样选择正确等待方式
| 需求 | 推荐方式 |
|---|---|
| 等待网络响应 | await fetch() 或请求库 Promise |
| 等待 Vue 更新 DOM | await nextTick() |
| 持续在某来源变化且 DOM 更新后处理 | watch(..., { flush: 'post' }) |
| 在浏览器下一帧执行 | requestAnimationFrame() |
| 延迟固定时间 | setTimeout() |
| 等待组件挂载 | onMounted() |
| 等待任意组件更新完成 | onUpdated(),但要谨慎控制范围 |
代码是否正确,往往取决于你是否明确自己在等待什么。
四十五、面试高频问题
1. Vue 的数据更新是异步的吗
可以这样回答:
响应式状态本身通常是同步修改的,异步的是由状态变化触发的组件渲染和 DOM 更新。Vue 会通过 scheduler 将组件更新任务放入队列,在当前同步代码结束后批量刷新,并对同一组件任务去重。
2. Vue 为什么采用异步更新队列
可以这样回答:
同一轮同步代码中可能连续修改多次状态。如果每次都立即渲染,会产生大量无意义的中间 VNode 计算和 DOM 操作。Vue 将更新任务缓存、去重并统一刷新,使同一组件通常只根据最终状态更新一次。
3. nextTick 是什么
可以这样回答:
nextTick 是等待 Vue 当前 DOM 更新刷新完成的工具。Vue 3 调度器会保存当前刷新 Promise,nextTick 在有待处理更新时等待这个 Promise,之后再执行回调或恢复 await 后的代码;没有待处理刷新时则基于已完成 Promise 安排后续微任务。
4. nextTick 是 setTimeout 吗
可以这样回答:
不是。setTimeout 安排的是后续计时器任务,nextTick 的语义是等待 Vue 当前更新队列完成。在现代 Vue 3 中它与当前调度刷新 Promise 关联,通常利用 Promise 微任务,而不是简单固定延迟。
5. nextTick 和 Promise.resolve 有什么区别
可以这样回答:
两者都可能涉及 Promise 微任务,但 Promise.resolve().then 只是在已完成 Promise 后登记回调;nextTick 在存在 Vue 待刷新任务时,会等待当前 flush Promise,表达的是"Vue 本轮 DOM 更新完成后继续"。因此不能把任意 Promise.resolve 当成 nextTick 的稳定替代品。
6. 连续修改三次状态,组件更新几次
可以这样回答:
状态会同步修改三次,相关依赖也可能被触发三次,但同一个组件更新任务在同一批次中通常只入队一次。队列刷新时组件读取最终状态并更新一次。具体还要考虑 sync watcher、不同任务边界以及更新过程中产生的新任务。
7. Vue 怎样对任务去重
可以这样回答:
教学模型可以用 Set 对相同函数去重。当前 Vue 3 调度器使用任务的入队状态标记并维护有序队列,避免同一任务在不允许重复入队时被反复添加,同时还要处理刷新期间新增任务和允许递归的特定回调。
8. 为什么父组件更新任务通常先执行
可以这样回答:
父组件先创建,通常拥有更小的组件 uid。调度队列根据任务 id 保持父先子后的顺序,这样父组件可以先更新子组件 props;如果父组件在更新中卸载子组件,子组件队列任务还可以被跳过。
9. 父子组件 updated 顺序是什么
可以这样回答:
组件更新任务通常父先子后,但更新完成钩子通常子先父后。Vue 官方明确说明父组件 updated 会在子组件 updated 之后执行。常见简化顺序是父 beforeUpdate、子 beforeUpdate、子 updated、父 updated,但仍要判断哪些组件实际发生了更新。
10. watch 默认什么时候执行
可以这样回答:
默认
flush: 'pre'。如果存在父组件更新,回调在父组件更新之后,并在侦听器所属组件的 DOM 更新之前执行,因此默认 watch 中读取当前组件 DOM 通常得到旧内容。
11. flush: post 有什么用
可以这样回答:
post watch 会在所属组件 DOM 更新后执行,适合读取或操作最新 DOM。
watchPostEffect是 post 模式 watchEffect 的便捷别名。
12. flush: sync 有什么风险
可以这样回答:
sync watcher 在依赖变化时同步触发,不经过常规批处理。频繁修改数组或循环赋值时可能执行大量回调,还容易形成递归更新,因此只适合少量、简单且确实要求同步响应的数据。
13. post watch 和 nextTick 怎样选择
可以这样回答:
如果需要持续监听某个来源,并在每次所属组件 DOM 更新后处理,使用 post watch;如果是在某个方法中完成一次明确状态修改后,只需要等待本轮 DOM 更新,使用 await nextTick 更直接。
14. nextTick 会等待请求和浏览器绘制吗
可以这样回答:
不会等待网络请求,网络 Promise 要单独 await。nextTick 主要等待 Vue 更新队列和 DOM 提交,也不应绝对等同于浏览器已经绘制到屏幕;需要帧边界时应结合 requestAnimationFrame。
四十六、一张完整流程图
text
用户点击按钮
↓
事件处理函数同步执行
↓
count.value++
↓
ref setter / reactive Proxy set
↓
trigger 通知组件 ReactiveEffect
↓
effect.scheduler()
↓
queueJob(componentUpdateJob)
↓
任务去重并按 id 放入队列
↓
安排 Promise 微任务 flushJobs
↓
继续执行当前同步代码
↓
当前调用栈清空
↓
刷新 Vue 队列
↓
执行 pre watcher
↓
父组件、子组件按调度顺序更新
↓
render 生成新 VNode
↓
patch 更新真实 DOM
↓
执行 post watcher 和 updated 钩子
↓
当前刷新 Promise 完成
↓
nextTick 后续代码执行
↓
浏览器在合适机会渲染页面
四十七、总结
本篇最重要的第一句话是:
text
Vue 的状态通常同步改变,DOM 更新经过异步调度。
状态变化后:
text
trigger 找到组件 effect;
scheduler 不立即渲染,而是把组件任务加入队列;
队列对同一任务去重,并维持必要的父子顺序;
当前同步代码结束后,通过微任务刷新队列;
组件读取最终状态,完成一次渲染和 DOM 更新。
nextTick 的重点不是"延迟一下",而是:
text
等待 Vue 当前更新队列刷新完成。
watch 的三种时机可以记成:
text
pre:所属组件 DOM 更新前,默认值;
post:所属组件 DOM 更新后;
sync:响应式变化时同步执行,不进行常规批处理。
最后再把几个概念放在正确位置:
text
Promise.then:微任务回调;
setTimeout:后续计时器任务;
nextTick:等待 Vue 更新刷新;
requestAnimationFrame:在浏览器下一次重绘前执行;
onUpdated:组件 DOM 更新后的生命周期钩子。
理解调度器后,专栏 05 的响应式链路才真正完整:
text
Proxy / ref 拦截变化
↓
track / trigger 管理依赖
↓
scheduler 调度组件任务
↓
render 重新生成虚拟 DOM
↓
patch 更新真实 DOM
下一篇将进入专栏 07:模板编译、虚拟 DOM、Diff 与 key,重点讲解:
text
Vue 模板怎样编译成 render 函数;
VNode 和真实 DOM 有什么区别;
响应式系统为什么要和虚拟 DOM 配合;
Vue 2 双端 Diff 怎样工作;
Vue 3 Diff 做了哪些优化;
key 为什么不能随便使用 index;
最长递增子序列解决了什么问题。