先记住这个答案
默认 flush pre 的 watch 回调会经过批量调度,在父组件更新之后(若有),所属组件自身 DOM 更新之前执行。因此源值已经变化,回调里读取自己的 DOM 却可能仍看到旧内容。这个顺序不是全站所有组件更新之前,也不是浏览器绘制之前的万能钩子。需要读取自身更新后的节点内容时,应改用 post,或在明确的更新操作之后等待 nextTick。
- pre 以侦听器所属组件为参照
- 新响应式值与旧 DOM 可以同时存在
- 默认回调会合并同一轮中的重复触发
参照对象是所属组件
父组件改变传给子组件的属性时,需要先推进父组件的更新流程,子组件才能接收到新输入。子组件自己的 pre 监听可以读到这些新输入,但它的节点补丁尚未完成,因此不能据此推断自己的 DOM 已经同步。
面试中不要画成所有监听先跑、所有组件后跑的全局两阶段流程。组件之间存在父子关系和各自任务排序,正确表述是相对所属组件的更新位置,并说明父级更新这一限定。
用模板引用读取旧文本
示例组件显示 count,监听同一个源并记录节点当前文本。组件先挂载为零,再把 count 改为一,pre 回调可以记录源为一而节点文本仍为零,这种组合是预期调度结果。
模板引用在挂载前可能为空,所以代码做了空值处理。这个保护只避免访问错误,并不能把缺少节点变成已经渲染完成;实际测量必须先确认对应条件分支确实产生了元素。
import { ref, watch, h } from 'vue'
const count = ref(0)
const seen: string[] = []
const PreProbe = {
setup() {
const label = ref<HTMLElement | null>(null)
watch(count, (value) => {
seen.push(value + ':' + (label.value?.textContent ?? 'missing'))
})
return () => h('span', { ref: label }, String(count.value))
}
}先挂载 PreProbe,再改变 count 并等待 nextTick,记录为一配旧文本零,最终节点文本为一。代码展示的是所属组件节点更新位置,不能用它证明浏览器已经完成绘制或布局动画。
不要为了立即读 DOM 改用 sync
sync 更早执行,仍然不会替你把组件 DOM 先更新好,反而失去默认批量合并。读取 DOM 的需求应选择更新后的时点,读取纯状态的需求则通常不需要等待任何节点变更。
如果一次回调只是请求数据或同步外部配置,默认 pre 往往已足够。只有真正依赖新节点结构、文本或尺寸时才安排 post 或 nextTick,以免引入无意义的等待和难以解释的执行顺序。
容易答错的地方
- 把 pre 解释为所有组件更新之前
- 这个结论忽略所属组件与父级之间的调度关系,遇到父组件传新属性就难以解释。回答时明确谁拥有监听、观察谁的节点,才能正确分析读到的是哪一层的更新前状态。
- 把新值等同于 DOM 已经更新
- 响应式赋值和节点补丁并非同一同步动作,回调参数取得新值不代表模板内容已经刷新。调试时同时记录源值和引用节点的文本,避免仅凭其中一个推断另一个。
面试官还会怎么问?
默认 watch 会在赋值语句内部立即执行吗?
一般会进入批量调度,在同步代码结束后的刷新过程中执行,而不是每次赋值都立即运行。若配置 sync 才改变这一点,但也会带来失去合并和读取中间状态等代价。
immediate true 的第一次回调能读到 DOM 吗?
不能仅凭 pre 或 immediate 推断节点已存在。立即调用发生在创建监听时,组件可能尚未挂载;需要初次节点操作时应使用挂载生命周期或适当的 post 效果,并保留空值判断。
只需要根据新值发请求,还要改 post 吗?
通常不需要。请求参数来自响应式状态时,pre 回调已经能够取得新值,等待 DOM 更新没有直接收益。只有请求流程确实依赖新节点信息时,才需要将这部分 DOM 操作放到更新后。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。