写 Vue 代码时,最容易出现的误会是:状态变了,DOM 就已经变了。
真实情况更像是:状态变了,Vue 先记录哪些组件需要更新,然后把更新任务放进队列,等当前同步代码执行完,再统一刷新 DOM。这个过程能减少重复渲染,但也会带来一个常见现象:刚改完数据马上读 DOM,读到的还是旧结果。
这篇不讲 Composition API 的组织技巧,只专门拆 Vue 3 从响应式触发到 DOM 更新的链路。
一句话理解
Vue 的组件更新是异步批量执行的。
你可以把它理解成三步:
- 响应式数据发生变化
- Vue 标记依赖这个数据的组件需要更新
- 当前同步代码结束后,Vue 批量重新渲染并 patch DOM
所以这段代码里,第二行拿到的 DOM 通常不是更新后的:
<script setup lang="ts">
import { nextTick, ref } from 'vue'
const count = ref(0)
const counterRef = ref<HTMLElement | null>(null)
async function increase() {
count.value++
console.log(counterRef.value?.textContent) // 大概率还是旧值
await nextTick()
console.log(counterRef.value?.textContent) // DOM 已完成更新
}
</script>
<template>
<button ref="counterRef" @click="increase">
{{ count }}
</button>
</template>nextTick 的意义不是"让数据更新",而是"等 Vue 把这一轮 DOM 更新做完"。
响应式触发的不是 DOM,而是组件更新
Vue 的响应式系统会记录谁读取了某个响应式数据。
当模板里使用了 count:
<template>
<p>{{ count }}</p>
</template>这个组件的渲染函数就会依赖 count。当 count.value++ 发生时,Vue 不会马上找到那个 p 标签直接改文本,而是通知依赖它的组件:"你需要重新渲染了"。
这点很关键。
Vue 的更新单位主要是组件,不是某个具体 DOM 节点。组件重新执行渲染函数后得到新的虚拟 DOM,再和旧虚拟 DOM 做对比,最后把差异 patch 到真实 DOM 上。
因此你写模板时,关注的是状态到视图的映射:
<template>
<article :class="{ active: selected }">
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
</article>
</template>你不需要手动说"改 h2 的 textContent"或"切换 p 的 display",Vue 会根据新的渲染结果处理 DOM 差异。
为什么要批量更新
如果每次状态变化都立刻重新渲染,下面这段代码会触发 3 次组件更新:
user.value.name = 'Ada'
user.value.role = 'admin'
user.value.updatedAt = Date.now()但用户真正需要看到的是最终状态,不是中间过程。
Vue 会把这些变化合并到同一轮更新里。也就是说,同一个组件在一次同步任务中被多次标记更新,最终通常只会重新渲染一次。
这就是批量更新的价值:
- 减少重复渲染
- 避免 DOM 频繁读写
- 保持视图只呈现最终状态
所以不要把 Vue 的异步更新理解成"慢",它更多是在帮你把碎片化变更合并成一次稳定的视图刷新。
nextTick 适合处理什么
nextTick 适合用在"状态变更之后,必须读取或操作更新后的 DOM"的场景。
典型例子是列表新增后滚到底部:
<script setup lang="ts">
import { nextTick, ref } from 'vue'
const messages = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
async function addMessage(text: string) {
messages.value.push(text)
await nextTick()
listRef.value?.scrollTo({
top: listRef.value.scrollHeight,
behavior: 'smooth'
})
}
</script>
<template>
<ul ref="listRef" class="message-list">
<li v-for="message in messages" :key="message">
{{ message }}
</li>
</ul>
</template>这里必须等 DOM 更新,因为 scrollHeight 依赖新增后的列表高度。如果不等 nextTick,你拿到的高度可能还没包含新消息。
但不要把 nextTick 当成万能补丁。下面这种写法通常说明逻辑边界有点乱:
form.value.name = 'Ada'
await nextTick()
form.value.email = 'ada@example.com'
await nextTick()
form.value.role = 'admin'如果只是为了让状态继续参与计算,不需要 nextTick。计算属性、watch、模板渲染都会跟着响应式系统走。
watch 的 flush 时机
watch 默认会在组件 DOM 更新前执行。多数业务逻辑没问题,但如果你在 watch 里读 DOM,就要明确时机。
watch(
() => keyword.value,
() => {
// 默认时机:状态已变,但当前组件 DOM 可能还没更新
}
)如果需要在 DOM 更新后运行,可以使用 flush: 'post':
watch(
() => keyword.value,
() => {
console.log(inputRef.value?.offsetWidth)
},
{
flush: 'post'
}
)flush: 'post' 的语义比在 watch 里手动 await nextTick() 更直接:这个副作用本来就依赖更新后的 DOM。
常见选择可以这样记:
- 只处理数据:默认 flush
- 需要读更新后的 DOM:
flush: 'post' - 极少数需要同步响应变化:
flush: 'sync',谨慎使用
key 决定复用边界
渲染更新不只是"改文本",还涉及节点和组件实例的复用。
在列表里,key 是 Vue 判断每一项身份的依据:
<template>
<UserCard
v-for="user in users"
:key="user.id"
:user="user"
/>
</template>如果你用数组下标当 key:
<UserCard
v-for="(user, index) in users"
:key="index"
:user="user"
/>当列表插入、删除、排序时,Vue 可能复用错组件实例。表单输入值、内部状态、动画状态都可能出现看起来很诡异的问题。
key 的原则很简单:
- 使用稳定且唯一的业务 id
- 不用随机数,否则每次渲染都会被当成新节点
- 不用 index 表示会变动的列表身份
如果你希望某个组件在条件变化时完全重建,也可以主动改变 key:
<UserEditor :key="userId" :user-id="userId" />这相当于告诉 Vue:不同 userId 对应不同编辑器实例,不要复用旧状态。
v-if 和 v-show 的更新差异
v-if 和 v-show 都能控制显示,但渲染成本不同。
v-if 是真正的挂载和卸载:
<Panel v-if="visible" />当 visible 从 false 变成 true,组件会被创建;从 true 变成 false,组件会被销毁。
v-show 是保留节点,只切换 CSS:
<Panel v-show="visible" />如果一个面板切换频繁,并且初始化成本较高,v-show 更合适。如果一个区域很少出现,或者不显示时不该保留内部状态,v-if 更合适。
这也是渲染机制的一部分:你选择的指令会影响 Vue 如何处理节点生命周期。
更新性能的几个小习惯
第一,不要在模板里写重计算。
<template>
<p>{{ expensiveFormat(order.items) }}</p>
</template>只要组件重新渲染,这个函数就会再次执行。更稳的做法是用 computed:
const formattedItems = computed(() => expensiveFormat(order.value.items))第二,大列表要控制响应式范围。
如果一个很大的静态配置对象不需要响应式,不要把它塞进深层 reactive。可以放到模块顶层,或者使用 markRaw 表达它不需要被追踪。
第三,组件拆分要围绕更新边界。
拆组件不是越细越好,但当一个页面里某块区域更新非常频繁时,把它拆成独立组件,可以让更新影响范围更清晰。
第四,慎用会导致整棵树变化的状态。
例如把鼠标位置、输入草稿、临时 hover 状态放进过高层级的全局状态,容易让很多不相关组件跟着更新。
调试时看什么
遇到"为什么它又渲染了"的问题,可以按这个顺序排查:
- 哪个响应式数据变化了
- 哪些模板或 computed 读取了它
- 组件是否接收了变化的 props
- 列表 key 是否稳定
- 是否把临时状态放到了过高层级
Vue Devtools 的组件更新高亮很有用。它能让你快速看到一次交互影响了哪些组件。
如果一个简单输入导致整页都在更新,问题往往不在输入框本身,而在状态边界设计得太粗。
总结
Vue 3 的更新链路可以概括为:
- 响应式数据变化
- 依赖它的组件被标记
- 更新任务进入队列
- 当前同步代码结束
- Vue 批量渲染组件并 patch DOM
nextTick之后可以读取更新后的 DOM
理解这条链路之后,很多问题都会变得清楚:为什么刚改数据读不到新 DOM,为什么 key 会影响状态复用,为什么 watch 有 flush 时机,为什么临时状态不该随便放到全局。
写 Vue 时,最稳的心法是:让状态描述视图,让 Vue 负责更新,只有在必须接触真实 DOM 时再使用 nextTick。