You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Vue3迁移后setTimeout防抖失效 复选框操作store commit立即触发

问题现象

我希望通过setTimeout实现防抖逻辑,控制store提交频次:当用户点击复选框时启动超时计时,等待用户停止操作一段时间后,再将选中的复选框状态commit到store,避免每次点击都触发状态更新。

  • 首次操作时逻辑运行正常:会等待超时冷却结束后才触发一次commit
  • 异常表现:第二次操作复选框时防抖逻辑失效,每次点击都会直接触发commit,不会等待超时计时完成

预期效果:仅当setTimeout计时结束后才执行store commit操作。
该组件由正常运行的Vue 2版本迁移而来。

正常运行的Vue 2版本代码
<template>
  <PerfectScrollbar>
    <el-menu>
      <p
        v-if="hasSomeSelectedItem"
        class="select-all"
        @click="setAllItems('deselect')"
      >
        {{ _$t('deselect_all') }}
      </p>
      <p v-else class="deselect-all" @click="setAllItems('select')">
        {{ _$t('select_all') }}
      </p>

      <el-menu-item v-for="item in items" :key="item.id">
        <span v-if="type === 'datasets'" class="separator-char">
          <p v-if="item.separatorChar">
            {{ item.separatorChar }}
          </p>
        </span>

        <el-checkbox v-model="item.selected" @change="saveItemsState()">
          {{ item.name }}
        </el-checkbox>
      </el-menu-item>
    </el-menu>
  </PerfectScrollbar>
</template>

<script>
import _clonedeep from 'lodash/cloneDeep'
import { capitalize } from '@/utils/string'
import { PROCESS_ENV } from '@/utils/legacy/process-env'
export default {
  props: {
    options: {
      type: Array,
      default: () => [],
    },
    type: {
      type: String,
      default: '',
    },
  },
  data() {
    return {
      items: [],
      timeout: null,
    }
  },
  computed: {
    hasSomeSelectedItem() {
      return this.items.some((item) => item.selected === true)
    },
    getSelectedIds() {
      return this.items.filter((item) => item.selected).map((item) => item.id)
    },
  },
  mounted() {
    this.initializeItems()
    this.saveItemsStateNow()
  },
  methods: {
    initializeItems() {
      const storedItemsIds =
        this.$store.getters['filters/get' + capitalize(this.type) + 'Many']
      const optionsCloned = _clonedeep(this.options)
      if (storedItemsIds === 'all' || storedItemsIds.length === 0) {
        this.items = optionsCloned.map((item) => {
          item.selected = true
          return item
        })
        this.saveItemsState()
      } else {
        this.items = optionsCloned.map((item) => {
          item.selected = storedItemsIds.includes(item.id)
          return item
        })
      }
    },
    saveItemsState() {
      // prevent update & store items for each click on item, wait 1 sec
      clearTimeout(this.timeout)
      this.timeout = setTimeout(() => {
        this.saveItemsStateNow()
      }, PROCESS_ENV.delay.oneSec)
    },
    saveItemsStateNow() {
      this.setItemsStateAll()
      this.setItemsState()
    },
    setItemsState() {
      this.$store.commit(
        'filters/set' + capitalize(this.type) + 'Many',
        this.getSelectedIds
      )
    },
    setItemsStateAll() {
      const isAllSelected = this.getSelectedIds.length === this.items.length
      this.$store.commit(
        'filters/set' + capitalize(this.type) + 'ManyAll',
        isAllSelected
      )
    },
    setAllItems(setter) {
      this.items.forEach((item) => {
        item.selected = setter === 'select'
      })
      this.saveItemsState()
    },
    _$t(label, params = {}) {
      return this.$t(
        `components.project_filters_sidebar.checkbox_menu.${label}`,
        params
      )
    },
  },
}
</script>
存在防抖失效问题的Vue 3组件代码
<template>
  <PerfectScrollbar>
    <el-menu>
      <p
        v-if="hasSomeSelectedItem"
        class="select-all"
        @click="setAllItems('deselect')"
      >
        {{ _$t('deselect_all') }}
      </p>
      <p v-else class="deselect-all" @click="setAllItems('select')">
        {{ _$t('select_all') }}
      </p>

      <el-menu-item v-for="item in items" :key="item.id">
        <span v-if="type === 'datasets'" class="separator-char">
          <p v-if="item.separatorChar">
            {{ item.separatorChar }}
          </p>
        </span>

        <el-checkbox v-model="item.selected" @change="saveItemsState()">
          {{ item.name }}
        </el-checkbox>
      </el-menu-item>
    </el-menu>
  </PerfectScrollbar>
</template>

<script lang="ts" setup>
import { ItemsDisplayer } from '@/models/ItemsDisplayer'
import _clonedeep from 'lodash/cloneDeep'
import { capitalize } from '@/utils/capitalize'
import { PropType, ref, onMounted, computed } from 'vue'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'

const store = useStore()
const { t } = useI18n()

const props = defineProps({
  options: {
    type: Array as PropType<ItemsDisplayer[]>,
    default: () => [],
  },
  type: {
    type: String,
    default: '',
  },
})

let items = ref<ItemsDisplayer[]>()
let timeout: ReturnType<typeof setTimeout>

onMounted(() => {
  initializeItems()
  saveItemsStateNow()
})

function initializeItems() {
  const storedItemsIds =
    store.getters['filters/get' + capitalize(props.type) + 'Many']
  const optionsCloned = _clonedeep(props.options)

  if (storedItemsIds === 'all' || storedItemsIds.length === 0) {
    items.value = optionsCloned.map((item) => {
      item.selected = true
      return item
    })
    saveItemsState()
  } else {
    items.value = optionsCloned.map((item) => {
      item.selected = storedItemsIds.includes(item.id)
      return item
    })
  }
}
function saveItemsState() {
  console.log('TIMEOUT ', timeout)
  // prevent update & store items for each click on item, wait 1 sec
  clearTimeout(timeout)
  console.log('TIMEOUT AFTER CLEAR', timeout)
  timeout = setTimeout(() => {
    console.log('DINS TIMEOUT ', timeout)
    saveItemsStateNow()
  }, 3000)
}
const hasSomeSelectedItem = computed(() => {
  return items.value?.some((item) => item.selected === true)
})

const getSelectedIds = computed(() => {
  return items.value?.filter((item) => item.selected).map((item) => item.id)
})

function saveItemsStateNow() {
  console.log('saveItemsStateNow')
  setItemsStateAll()
  setItemsState()
}

function setItemsState() {
  store.commit('filters/set' + capitalize(props.type) + 'Many', getSelectedIds)
}

function setItemsStateAll() {
  const isAllSelected = getSelectedIds.value?.length === items.value?.length
  store.commit(
    'filters/set' + capitalize(props.type) + 'ManyAll',
    isAllSelected
  )
}

function setAllItems(setter: string) {
  items.value?.forEach((item) => {
    item.selected = setter === 'select'
  })
  saveItemsState()
}

function _$t(label: string, params = {}) {
  return t(`components.project_filters_sidebar.checkbox_menu.${label}`, params)
}
</script>
问题根因

防抖逻辑本身没有问题,异常来自两个编码错误:

  1. 核心错误:将响应式ref对象存入了Vuex store
    Vue2中this.getSelectedIds直接返回计算后的普通ID数组,存入store的是静态值;但Vue3的composition API中,getSelectedIds是ComputedRef类型的响应式对象,你在setItemsState方法中直接传递getSelectedIds而非getSelectedIds.value,导致把整个响应式ref存入了store。
    首次防抖结束执行commit后,store中存储的是这个ref的引用,后续你每次修改复选框选中状态,ref的value会自动更新,store中的值也会同步变化,造成了「每次点击都触发commit」的假象,实际上commit只执行了一次,后续的状态更新是响应式引用自动同步导致的。
  2. 次要逻辑问题:
    • itemsref初始值为undefined,timeout变量未初始化,和Vue2逻辑不对齐,极端场景会出现意外
    • onMounted中调用initializeItems时如果走默认全选分支,会先创建一个延迟提交的timeout,之后同步调用saveItemsStateNow时没有清除这个timeout,会导致延迟到点后重复提交
修复方案

修改对应代码即可:

  1. 给items和timeout设置初始值,对齐Vue2逻辑:
let items = ref<ItemsDisplayer[]>([])
let timeout: ReturnType<typeof setTimeout> | null = null
  1. 修改setItemsState方法,传入computed的实际值,建议拷贝一份普通数组避免响应式引用污染store:
function setItemsState() {
  store.commit(
    'filters/set' + capitalize(props.type) + 'Many',
    [...(getSelectedIds.value || [])]
  )
}
  1. 修改onMounted逻辑,调用saveItemsStateNow前先清除可能存在的timeout,避免重复提交:
onMounted(() => {
  initializeItems()
  if (timeout) clearTimeout(timeout)
  saveItemsStateNow()
})
  1. 可选优化:saveItemsState中清除timeout后可以把timeout重置为null,方便调试:
function saveItemsState() {
  if (timeout) clearTimeout(timeout)
  timeout = setTimeout(() => {
    saveItemsStateNow()
    timeout = null
  }, 3000)
}

内容的提问来源于stack exchange,提问作者Airea

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.29 16:12:28