Code前端首页关于Code前端联系我们

Vue3 watch的flush: post到底有什么用?实际开发场景怎么选?

terry 51分钟前 阅读数 23 #Vue

刷到不少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值:

  1. 默认值flush: 'pre':当你只需要处理数据层面的逻辑,不需要获取最新DOM的时候,用pre就够了,比如修改其他数据、提前过滤数据、给父组件发事件等。
  2. flush: 'sync':只有在非常特殊的实时同步需求下才用,比如第三方库的事件必须和数据严格绑定同步触发,否则会导致功能异常,一般情况下别碰,会影响性能。
  3. flush: 'post':当你监听数据变化后需要获取最新DOM或者操作DOM的时候,优先用post,它比nextTick更简洁,逻辑更内聚,比如折叠面板高度计算、图表重新渲染、表格列宽自适应等。
  4. nextTick:当你在非数据驱动的DOM操作后需要获取状态,或者在watch之外需要获取最新DOM,或者需要在多个DOM更新阶段操作的时候,用nextTick。

记住这个选择原则,以后遇到Vue3的watch监听DOM问题,就不会再踩坑了!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门