Vue3 watch的flush: post到底有什么用?实际开发场景怎么选?
刷到不少Vue3新手开发者吐槽watch监听DOM后获取不到最新值的问题,还有人明明写了nextTick但代码还是有点“绕”,今天咱们就把这个flush: 'post'说透——它不是nextTick的替代品,但在很多监听DOM的场景里是更顺的工具,连Vue核心文档里都把它归为watch的“高级配置”之一。
flush到底是干嘛的?先搞懂watch的三种触发时机
很多人一开始只知道watch可以监听数据,根本没注意到配置项里还有flush这个参数,它的作用就是精准控制watch回调函数的执行时机,让我们能刚好在想要的节点拿到DOM或者数据的状态。
Vue3官方给出了flush的三个可选值:'pre'、'sync'、'post',咱们先从最熟悉的默认值讲起,再逐个对比,这样flush: 'post'的优势才能凸显出来。
默认值flush: 'pre':组件更新前触发
写过Vue2到Vue3的人可能会有点印象,Vue2的watch默认其实有点像post的感觉?不对不对,别搞混了——Vue3默认把回调的执行时机提前到了组件DOM批量更新之前、监听数据发生变化的任务之后。
举个最简单的例子:你有个div显示count,然后用watch监听count,同时在回调里打印count值和div的innerText,假设count从0变成1,你会发现打印的count是1,但div的innerText还是0,这就是pre的特点:它确保了你拿到的是最新的数据源,但此时浏览器还没来得及把新数据渲染到DOM上。
这种时机适合做什么呢?比如数据变化后,你想提前做点计算、修改其他数据但不想导致无限循环,或者想阻止DOM更新后的一些默认行为?不对,阻止默认行为可能用sync更直接,但pre的核心是不碰最新的渲染结果,只处理数据层面的逻辑。
很少用的flush: 'sync':数据变化后立刻同步触发
这个配置项更“激进”——监听的数据一变,不管当前有没有其他任务,watch的回调立刻同步跑,甚至比Vue的批量更新队列还要早。
还是刚才的count例子,sync情况下count变1,回调立刻打印count是1,div innerText还是0,这和pre好像没区别?那它的场景在哪呢?比如你有个高频更新的小范围数据,比如输入框的实时验证?但输入框实时验证其实用computed或者防抖的pre watch可能更稳,sync的问题是如果数据更新太频繁,会打断Vue的批量更新机制,导致性能下降,所以除非非常特殊的实时同步需求(比如某个第三方库的事件必须和数据严格绑定同步触发),一般不推荐用。
今天的主角flush: 'post':组件DOM批量更新后触发
终于到重点了!post的时机刚好和pre反过来:监听数据变化、Vue完成整个组件(或者相关子组件)的DOM批量更新、浏览器也把最新的渲染结果应用到页面上之后,watch的回调才会执行。
再用count的例子试一下:div显示count,count从0变1,watch用post监听,回调里打印count和div的innerText——这次两个都是1了!完美解决了新手常遇到的“监听数据变了但DOM没跟上”的问题。
等等,那nextTick不也是干这个的吗?为啥还要单独出个flush: 'post'?别急,咱们后面会专门讲两者的区别和适用场景。
flush: 'post' vs nextTick:谁更适合你的代码?
这应该是大家最关心的问题了——毕竟很多人已经习惯了用nextTick包一层获取DOM的代码,其实两者在最终拿到的状态上是一样的,但在代码的整洁性、逻辑的内聚性上,flush: 'post'有时候能赢nextTick一大截。
场景1:单一监听数据触发DOM操作
比如你有个“回到顶部”的按钮,当页面滚动到一定高度时显示,否则隐藏,不对,这可能用滚动事件监听更直接?换个更贴合Vue3数据驱动的例子:你有个折叠面板组件,点击按钮切换collapseData的值来控制显示隐藏,每次切换后都要获取折叠面板内部内容的高度,用来计算动画的持续时间或者滚动位置。
如果用默认的pre watch,你必须把获取高度的代码包在nextTick里,代码大概长这样:
import { ref, watch, nextTick } from 'vue'
const collapseData = ref(false)
watch(collapseData, async (newVal) => {
// 先处理数据变化后的其他逻辑,比如给父组件发事件
emit('toggle', newVal)
// 必须用nextTick等DOM渲染
await nextTick()
// 然后才能拿到最新的高度
const contentHeight = document.querySelector('.collapse-content')?.offsetHeight || 0
// 计算动画
// ...
})
看起来也还行?但如果用flush: 'post',你就可以省去async/await和nextTick的嵌套,逻辑更顺:
import { ref, watch } from 'vue'
const collapseData = ref(false)
watch(collapseData, (newVal) => {
emit('toggle', newVal)
// 直接就能拿到高度!因为post保证DOM已经更新了
const contentHeight = document.querySelector('.collapse-content')?.offsetHeight || 0
// 计算动画
// ...
}, { flush: 'post' })
对比一下就能发现,post的写法把“监听数据→处理数据→获取DOM→操作DOM”的逻辑完全内聚在一个watch回调里,没有额外的异步等待,可读性和可维护性都提升了。
场景2:多个监听数据触发同一DOM操作
这时候post的优势就更大了!假设你有个图表组件,需要监听两个数据:dataSource(图表数据源)和chartSize(容器大小),只要其中一个变了,就要重新渲染ECharts图表,ECharts的setOption必须在DOM容器有确定大小的时候才能正常工作,所以不管是哪个数据变了,都要等DOM更新。
如果用nextTick,你要么写两个watch,每个里面都包一层nextTick,要么写一个watch监听数组,但数组的两个元素变化时可能会触发两次nextTick?不对,Vue3的批量更新机制会把同一个事件循环里的数据变化合并,所以nextTick可能只触发一次,但代码还是要写嵌套:
import { ref, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
const dataSource = ref([])
const chartSize = ref({ width: 0, height: 0 })
const chartInstance = ref(null)
watch([dataSource, chartSize], async () => {
if (!chartInstance.value) return
// 每次都要等
await nextTick()
// 调整容器大小
chartInstance.value.resize()
// 重新渲染数据
chartInstance.value.setOption({ series: [{ data: dataSource.value }] })
})
如果用flush: 'post',不仅省去了嵌套,逻辑更清晰,而且你不用担心事件循环的问题——因为post本身就是在批量DOM更新后触发的,不管是dataSource变、chartSize变,还是两者一起变,回调只会在所有相关DOM都更新完之后执行一次(如果两个数据在同一个事件循环里变的话),完全符合ECharts的渲染需求。
什么时候必须用nextTick?
说了这么多post的好,nextTick是不是没用了?当然不是!nextTick的适用场景更广,而且有些情况下post解决不了问题,必须用nextTick。
第一个场景是非数据驱动的DOM操作触发后获取状态,比如你手动用document.createElement添加了一个元素到DOM里,这时候Vue的批量更新队列根本不会触发,所以post watch也不会起作用,必须用nextTick等浏览器把这个手动添加的元素渲染出来。
第二个场景是在watch之外需要获取最新DOM的情况,比如你点击某个按钮后直接做了DOM操作,然后立刻要获取操作后的状态,这时候没必要专门写个watch来监听,直接包一层nextTick就行。
第三个场景是需要在多个DOM更新阶段操作的情况,比如你先修改了一个数据A,需要等DOM更新后(用nextTick或者post watch修改数据B),再修改数据B,再等DOM更新后操作数据C,这时候nextTick的链式调用或者async/await会更灵活。
避坑指南:用flush: 'post'时要注意这三点
虽然flush: 'post'很好用,但如果用错了也会出问题,新手特别容易踩这三个坑。
坑1:不要在flush: 'post'的回调里修改监听的数据本身,或者修改其他会触发同一组件DOM更新的数据
等等,pre watch里不能随便修改监听的数据,怕无限循环,post watch里也不能?是的,虽然post watch是在DOM更新后触发的,但修改监听的数据或者相关数据后,Vue会再次触发更新流程,包括pre watch、DOM更新、post watch,如果逻辑没写好(比如没有条件判断),就会陷入无限循环。
比如下面的代码,就会无限打印:
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newVal) => {
console.log(newVal)
count.value++ // 没有条件判断,每次都加1,无限循环
}, { flush: 'post' })
pre watch里加条件判断可以避免,post watch里也一样——必须确保只有在符合特定条件时才修改数据。
坑2:flush: 'post'只对当前watch的回调生效,不会影响其他watch或者组件的更新
比如你有两个watch,一个用pre,一个用post,pre的回调还是会在DOM更新前触发,和post的互不干扰,这点很重要,别以为加了一个post watch,整个组件的更新顺序就变了。
坑3:监听ref或者reactive的深层属性时,flush: 'post'同样有效,但要注意deep配置
这点其实和flush本身没关系,但很多人会把deep和flush搞混——deep是用来控制是否监听对象/数组内部属性变化的,flush是控制回调时机的,两者可以同时使用,互不冲突。
比如你有个reactive的user对象,里面有个info属性,info里有个address属性,你想监听address的变化,等DOM更新后显示用户的完整地址,就可以同时用deep: true和flush: 'post':
import { reactive, watch } from 'vue'
const user = reactive({
info: {
name: '张三',
address: '北京市朝阳区'
}
})
watch(() => user.info.address, (newVal) => {
// 直接操作DOM显示完整地址
document.querySelector('.full-address').innerText = `${user.info.name}的地址是${newVal}`
}, { flush: 'post' })
实际开发中的一个复杂案例:表格列宽自适应
光说简单的例子可能不够直观,咱们来看一个实际开发中经常遇到的复杂案例:表格列宽自适应。
需求是这样的:有一个可滚动的表格组件,表格的列数、列的标题、列的数据都是动态变化的,每次变化后都要根据内容的最长长度自动调整列宽,而且要确保表格的总宽度不小于容器的宽度,否则最后一列要撑满剩余空间。
如果用默认的pre watch,你会发现每次获取的列内容宽度都是上一次的,导致列宽调整错误;如果用nextTick,代码会有很多嵌套,而且如果列数、标题、数据同时变化,可能会触发多次调整;如果用flush: 'post',就可以完美解决这些问题。
咱们来写一下核心代码:
import { ref, watch, onMounted, onUnmounted } from 'vue'
// 模拟动态数据
const tableColumns = ref([])
const tableData = ref([])
const containerRef = ref(null)
const tableRef = ref(null)
// 监听窗口大小变化,因为容器宽度可能变
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
const handleResize = () => {
windowSize.value = { width: window.innerWidth, height: window.innerHeight }
}
// 用一个watch同时监听所有会影响列宽的因素:列配置、数据、窗口大小
watch([tableColumns, tableData, windowSize], () => {
if (!containerRef.value || !tableRef.value || tableColumns.value.length === 0) return
// 获取表格容器的可用宽度(减去滚动条宽度)
const containerWidth = containerRef.value.clientWidth
const scrollbarWidth = containerRef.value.offsetWidth - containerWidth
const availableWidth = containerWidth - scrollbarWidth
// 获取所有列的DOM元素
const columnHeaders = tableRef.value.querySelectorAll('.table-header th')
const columnBodies = tableRef.value.querySelectorAll('.table-body td')
// 计算每一列的最大内容宽度
const columnMaxWidths = []
columnHeaders.forEach((header, index) => {
// 表头的宽度
let maxWidth = header.scrollWidth
// 遍历该列的所有单元格,找最大宽度
for (let i = index; i < columnBodies.length; i += columnHeaders.length) {
const cellWidth = columnBodies[i].scrollWidth
if (cellWidth > maxWidth) {
maxWidth = cellWidth
}
}
// 加上一点 padding,防止内容贴边
columnMaxWidths.push(maxWidth + 20)
})
// 计算所有列的总最大宽度
const totalMaxWidth = columnMaxWidths.reduce((sum, width) => sum + width, 0)
// 如果总最大宽度小于可用宽度,最后一列撑满剩余空间
if (totalMaxWidth < availableWidth) {
const lastColumnExtraWidth = availableWidth - (totalMaxWidth - columnMaxWidths[columnMaxWidths.length - 1])
columnMaxWidths[columnMaxWidths.length - 1] = lastColumnExtraWidth
}
// 给每一列设置宽度
columnHeaders.forEach((header, index) => {
header.style.width = `${columnMaxWidths[index]}px`
header.style.minWidth = `${columnMaxWidths[index]}px`
})
columnBodies.forEach((cell, index) => {
const columnIndex = index % columnHeaders.length
cell.style.width = `${columnMaxWidths[columnIndex]}px`
cell.style.minWidth = `${columnMaxWidths[columnIndex]}px`
})
}, { flush: 'post', immediate: true }) // immediate: true 保证组件挂载后立刻执行一次
// 生命周期钩子
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
你看,这个案例里同时监听了三个动态因素,用flush: 'post'完美保证了每次获取的DOM元素都是最新渲染的,列宽计算完全正确,而且代码逻辑清晰,没有任何嵌套的nextTick,可读性非常高。
怎么选flush的三个值?
最后咱们来做个总结,方便大家快速判断该用哪个flush值:
- 默认值flush: 'pre':当你只需要处理数据层面的逻辑,不需要获取最新DOM的时候,用pre就够了,比如修改其他数据、提前过滤数据、给父组件发事件等。
- flush: 'sync':只有在非常特殊的实时同步需求下才用,比如第三方库的事件必须和数据严格绑定同步触发,否则会导致功能异常,一般情况下别碰,会影响性能。
- flush: 'post':当你监听数据变化后需要获取最新DOM或者操作DOM的时候,优先用post,它比nextTick更简洁,逻辑更内聚,比如折叠面板高度计算、图表重新渲染、表格列宽自适应等。
- nextTick:当你在非数据驱动的DOM操作后需要获取状态,或者在watch之外需要获取最新DOM,或者需要在多个DOM更新阶段操作的时候,用nextTick。
记住这个选择原则,以后遇到Vue3的watch监听DOM问题,就不会再踩坑了!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


