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

子组件更新Inertia useForm时的响应性异常问题排查

响应性异常排查:FilterMultiSelector更新Inertia useForm时显示错误选中项

问题描述

应用中使用多个FilterMultiSelector组件,子组件向父组件触发变更时更新Inertia的useForm,出现响应性异常:

  • 添加Foo、Bar、Lorem三个资产后,移除Lorem,显示Foo和Bar(正常)
  • 再移除Bar,却显示Foo和Lorem(异常)
  • 移除Foo则显示Bar和Lorem(异常)

父组件代码

<script setup>
import {useForm, usePage} from '@inertiajs/vue3'
import FilterMultiSelector from '@/Composables/FilterMultiSelector.vue'

const page = usePage()

defineProps({
    assets: {
        type: Array,
    },
})

const form = useForm({
    name: '',
    assets: [],
})
const submit = () => {
    form.post(route('store'), {
        onFinish: () => {
            form.name = ''
            form.assets = []
        },
    })
}

const handleSelectedAssetsUpdate = (objects) => {
    console.log(objects)
    form.assets = [...objects]
}
</script>

<template>
    
  <div class="my-2">
      <FilterMultiSelector
          :objects="assets"
          name="Assets"
          :filterAttributes="['label', 'property_name']"
          @update:selectedObjects="handleSelectedAssetsUpdate"
          :selected-objects="form.assets">
          <template #displayFilter="{object}">
              <div class="flex justify-between text-xs">
                  <span class="font-bold">{{ object.label }}</span>
                  <span class="">{{ object.property_name }}</span>
              </div>
          </template>
          <template #displaySelection="{object}">
              <div class="flex justify-between gap-2">
                  <span class="font-bold">{{ object.label }}</span>
                  <span>{{ object.property_name }}</span>
              </div>
          </template>
      </FilterMultiSelector>
  </div>

</template>

FilterMultiSelector子组件代码

<script setup>
import {computed, ref} from 'vue'
import useDetectOutsideClick from '@/Composables/useDetectOutsideClick.js'
import InputLabel from '@/Components/InputLabel.vue'
import TextInput from '@/Components/TextInput.vue'
import {XMarkIcon} from '@heroicons/vue/24/outline'

let {name, objects, filterAttributes, selectedObjects} = defineProps({
    name: {
        type: String,
    },
    objects: {
        type: Array,
    },
    filterAttributes: {
        type: Array,
        default: () => [],
    },
    selectedObjects: {
        type: Array,
        default: () => [],
    },
})
const emit = defineEmits(['update:selectedObjects'])

const componentRef = ref()
const inputRef = ref()
const searchString = ref('')

const filteredObjects = computed(() => {
    return objects.filter((object) => {
        const searchLower = searchString.value.toLowerCase()

        return filterAttributes.length > 0
            ? filterAttributes.some((key) => {
                  const value = object[key]
                  return typeof value === 'string' && value.toLowerCase().includes(searchLower)
              }) && !selectedObjects.includes(object)
            : Object.keys(object).some((key) => {
                  const value = object[key]
                  return typeof value === 'string' && value.toLowerCase().includes(searchLower)
              }) && !selectedObjects.includes(object)
    })
})

const add = (object) => {
    if (!selectedObjects.includes(object)) {
        selectedObjects.push(object)
        emit('update:selectedObjects', selectedObjects)
    }
    searchString.value = ''
    inputRef.value.focus()
}

const remove = (object) => {
    const newSelectedObjects = selectedObjects.filter((t) => t.id !== object.id)
    emit('update:selectedObjects', [...newSelectedObjects])
}

useDetectOutsideClick(componentRef, () => {
    searchString.value = ''
})
</script>

<template>
    <div class="relative">
        <InputLabel
            for="object"
            :value="name" />
        <TextInput
            ref="inputRef"
            id="object"
            type="text"
            class="mt-1 block w-full"
            v-model="searchString"
            placeholder="Search object ..."
            auotofocus />

        <div
            v-if="filteredObjects.length && searchString"
            class="absolute left-[.5%] top-[60px] z-10 w-[99%] rounded border bg-white p-4 shadow"
            ref="componentRef">
            <div
                v-for="object in filteredObjects.slice(0, 10)"
                :key="object.id"
                @click="add(object)"
                class="rounded px-2 py-1 text-sm hover:cursor-pointer hover:bg-gray-100">
                <slot
                    name="displayFilter"
                    :object="object" />
            </div>
            <div
                v-if="filteredObjects.length > 10"
                class="pl-2 text-xs tracking-widest">
                ...
            </div>
        </div>

        <div
            v-if="selectedObjects.length"
            class="mt-2 flex flex-wrap gap-2">
            <div
                v-for="object in selectedObjects"
                :key="object.id"
                class="inline flex max-w-full items-center whitespace-nowrap rounded bg-primary-500 px-2 py-1 text-xs text-white hover:cursor-pointer hover:bg-primary-400"
                @click="remove(object)">
                <slot
                    name="displaySelection"
                    :object="object" />
                <XMarkIcon class="ml-1 h-4 w-4" />
            </div>
        </div>
    </div>
</template>

assets数据结构

assets: Array
   0: Reactive
      id: 1
      label:"Foo"
      property_name:"Bar"
   1 ...

原因分析

核心问题在于子组件直接修改父组件传递的props数组,结合Inertia useForm的响应性机制,导致引用混乱:

  1. 破坏单向数据流:子组件add方法中直接调用selectedObjects.push(object),修改了父组件form.assets的原数组引用(props为引用类型),导致父子组件共享同一数组实例。
  2. Inertia响应性追踪失效:useForm的表单对象是封装后的响应式对象,直接修改数组内部元素(如push)无法被其响应系统正确追踪,导致视图更新异常。
  3. 数组引用不一致:移除操作创建了新数组,但添加操作仍在旧引用上修改,导致组件间数组状态始终混乱,视图渲染基于错误的数组实例。

解决方案

1. 子组件中避免直接修改props,始终传递新数组

修改子组件add方法,创建新数组后再emit给父组件:

const add = (object) => {
    if (!selectedObjects.includes(object)) {
        // 创建新数组,保留原props引用的独立性
        const newSelected = [...selectedObjects, object]
        emit('update:selectedObjects', newSelected)
    }
    searchString.value = ''
    inputRef.value.focus()
}

2. 子组件使用本地状态维护选中项(可选优化)

通过本地ref隔离props,避免直接依赖父组件传递的数组,同时监听props变化同步状态:

<script setup>
// 替换原selectedObjects的直接使用
import {computed, ref, watch} from 'vue'

const localSelected = ref([...selectedObjects])

// 监听父组件props变化,同步本地状态
watch(() => selectedObjects, (newVal) => {
    localSelected.value = [...newVal]
}, { deep: true })

// 修改add和remove方法,基于localSelected操作
const add = (object) => {
    if (!localSelected.value.includes(object)) {
        localSelected.value = [...localSelected.value, object]
        emit('update:selectedObjects', localSelected.value)
    }
    searchString.value = ''
    inputRef.value.focus()
}

const remove = (object) => {
    localSelected.value = localSelected.value.filter(t => t.id !== object.id)
    emit('update:selectedObjects', localSelected.value)
}

// 修改filteredObjects计算属性,使用localSelected
const filteredObjects = computed(() => {
    return objects.filter((object) => {
        const searchLower = searchString.value.toLowerCase()

        return filterAttributes.length > 0
            ? filterAttributes.some((key) => {
                  const value = object[key]
                  return typeof value === 'string' && value.toLowerCase().includes(searchLower)
              }) && !localSelected.value.includes(object)
            : Object.keys(object).some((key) => {
                  const value = object[key]
                  return typeof value === 'string' && value.toLowerCase().includes(searchLower)
              }) && !localSelected.value.includes(object)
    })
})
</script>

3. 验证父组件数组更新的引用唯一性

在父组件handleSelectedAssetsUpdate中可添加日志,确认每次接收的都是新数组:

const handleSelectedAssetsUpdate = (objects) => {
    console.log('新数组与原数组是否同一引用:', objects === form.assets)
    form.assets = [...objects]
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.05 02:14:53