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

Vue3非组合式API下自定义array-field组件v-model类型异常问题

Vue 2迁移Vue 3后自定义array-field组件v-model类型异常(数组变字符串)

问题概述

从Vue 2迁移到Vue 3(使用Options API),基于Vuetify v-text-field封装的array-field组件,原本支持生成多个输入框并返回字符串数组给后端,但现在输入内容后,绑定的v-model会从数组变为字符串,导致后端请求失败,Vue 2环境下功能正常。

组件代码(原异常版本)

<template>
  <div
    ref="array_field"
    class="array_field__wrapper"
  >
    <v-text-field
      v-for="(element, index) in elements"
      v-bind="$attrs"
      :key="index"
      v-model="elements[index]"
      :label="isFirstValue(index) ? labelText : ''"
      class="array_field"
      :rules="rules.name"
      dense
      outlined
      :class="{ 'indent_right': !isLastValue(index) }"
      :append-inner-icon="isEmptyArray ? null : $icons['mdi-close']"
      :append-icon="isLastValue(index) ? $icons['mdi-plus'] : null"
      @click:append="addValue(index)"
      @click:append-inner="deleteValue(index)"
      @update:modelValue="onInput"
      @blur="onBlur"
    >
      <template #message="{ message }">
        {{ translateError(errorMessage, message) }}
      </template>
    </v-text-field>
  </div>
</template>

<script>
import { isEmptyOrNil } from '@/utils'

export default {
  props: {
    value: Array,
    labelText: String,
    translateError: Function,
    errorMessage: String
  },
  data () {
    return {
      elements: !isEmptyOrNil(this.value) ? this.value : [''],
      isEmptyArray: isEmptyOrNil(this.value),
      rules: {}
    }
  },
  watch: {
    elements (value) {
      this.$emit('input', this.isEmptyArray ? [] : value)
    }
  },
  mounted () {
    if (!isEmptyOrNil(this.value) && isEmptyOrNil(this.value[0])) {
      this.setLabelPositionOnTop()
    }
  },
  methods: {
    isFirstValue (index) {
      return index === 0
    },
    isLastValue (index) {
      return index === this.elements.length - 1
    },
    addValue (index) {
      this.elements[index + 1] = ''
      this.isEmptyArray = false
      this.setLabelPositionOnTop()
    },
    deleteValue (index) {
      if (this.elements.length > 1) {
        this.elements.splice(index, 1)
      } else {
        this.elements = ['']
        this.isEmptyArray = true
        this.setLabelPositionByDefault()
      }
    },
    setLabelPositionOnTop () {
      const commonWrapper = this.$refs.array_field.firstChild.firstChild.firstChild
      const legendWrapper = commonWrapper.firstChild
      const labelWrapper = legendWrapper.nextSibling
      const label = labelWrapper.firstChild
      label.classList.add('v-label--active')
      const legend = legendWrapper.querySelector('legend').querySelector('span')
      legend.style.color = 'white'
      legend.style.fontSize = '12px'
      legend.style.textWrap = 'nowrap'
      legend.style.padding = '2px'
      const labelText = label.innerHTML
      legend.innerHTML = labelText
    },
    setLabelPositionByDefault () {
      const commonWrapper = this.$refs.array_field.firstChild.firstChild.firstChild
      const legendWrapper = commonWrapper.firstChild
      const labelWrapper = legendWrapper.nextSibling
      const label = labelWrapper.firstChild
      label.classList.remove('v-label--active')
      const legend = legendWrapper.querySelector('legend').querySelector('span')
      legend.innerHTML = '\u200b'
      legend.style.padding = 0
    },
    onInput (value) {
      this.isEmptyArray = false
      console.log(value)
    },
    onBlur () {
      if (!this.isEmptyArray) {
        this.setLabelPositionOnTop()
      }
    }
  }
}
</script>

组件使用示例

<isf-array-field
   v-model="authSource.add.ldap_phone"
/>

弹窗Data定义

data () {
    const authSourceTemplate = {
      add: {
        name: null,
        url: null,
        base_dn: null,
        login: null,
        password: null,
        type: 'ldap',
        ldap_filter: null,
        ldap_phone: [],
        ldap_email: [],
        ldap_uuid: [],
        ldap_lastname: [],
        ldap_firstname: [],
        ldap_middlename: [],
        ldap_internal_phone: [],
        with_internal_phone: null
      }
    }
    return {
      authSourceTemplate: authSourceTemplate,
      authSource: this.clone(authSourceTemplate),
      actionPending: false,
      authSourcesDialogsProp: this.authsourcesDialogs,
      rules: {}
    }
}

问题原因

  1. Vue3 v-model协议不兼容:Vue2使用value prop + input事件实现v-model,而Vue3默认使用modelValue prop + update:modelValue事件。当前组件仍沿用Vue2的协议,但外层使用Vue3的v-model时,会自动传递modelValue和监听update:modelValue,组件未处理这些,导致绑定逻辑混乱。
  2. $attrs传递冲突:组件使用v-bind="$attrs"将所有未声明的prop传递给内部v-text-field,其中包含Vue3 v-model的modelValue和onUpdate:modelValue,导致内部单个输入框的字符串更新直接触发外层v-model,将数组覆盖为字符串。
  3. 不必要的事件绑定:内部v-text-field绑定了@update:modelValue="onInput",该事件返回单个输入字符串,进一步干扰了外层v-model的类型。

修复方案

修改后的组件代码

<template>
  <div
    ref="array_field"
    class="array_field__wrapper"
  >
    <v-text-field
      v-for="(element, index) in elements"
      v-bind="filteredAttrs"
      :key="index"
      v-model="elements[index]"
      :label="isFirstValue(index) ? labelText : ''"
      class="array_field"
      :rules="rules.name"
      dense
      outlined
      :class="{ 'indent_right': !isLastValue(index) }"
      :append-inner-icon="isEmptyArray ? null : $icons['mdi-close']"
      :append-icon="isLastValue(index) ? $icons['mdi-plus'] : null"
      @click:append="addValue(index)"
      @click:append-inner="deleteValue(index)"
      @blur="onBlur"
    >
      <template #message="{ message }">
        {{ translateError(errorMessage, message) }}
      </template>
    </v-text-field>
  </div>
</template>

<script>
import { isEmptyOrNil } from '@/utils'

export default {
  inheritAttrs: false, // 阻止未声明prop自动传递到根元素
  props: {
    modelValue: Array, // 适配Vue3 v-model的prop名
    labelText: String,
    translateError: Function,
    errorMessage: String
  },
  data () {
    return {
      elements: !isEmptyOrNil(this.modelValue) ? [...this.modelValue] : [''], // 浅拷贝避免修改原prop
      isEmptyArray: isEmptyOrNil(this.modelValue),
      rules: {}
    }
  },
  computed: {
    filteredAttrs () {
      // 过滤v-model相关属性,避免传递给内部输入框
      const { modelValue, 'onUpdate:modelValue': _onUpdateModelValue, ...rest } = this.$attrs
      return rest
    }
  },
  watch: {
    elements (value) {
      // 过滤空值后,触发Vue3的v-model更新事件
      const filteredValues = value.filter(item => !isEmptyOrNil(item))
      this.$emit('update:modelValue', filteredValues.length > 0 ? filteredValues : [])
    },
    // 同步外部modelValue的变化到内部elements
    modelValue (newVal) {
      if (!isEmptyOrNil(newVal)) {
        this.elements = [...newVal]
        this.isEmptyArray = false
      } else {
        this.elements = ['']
        this.isEmptyArray = true
      }
    }
  },
  mounted () {
    if (!isEmptyOrNil(this.modelValue) && isEmptyOrNil(this.modelValue[0])) {
      this.setLabelPositionOnTop()
    }
  },
  methods: {
    isFirstValue (index) {
      return index === 0
    },
    isLastValue (index) {
      return index === this.elements.length - 1
    },
    addValue (index) {
      this.elements.splice(index + 1, 0, '') // 用splice保证数组响应式更新
      this.isEmptyArray = false
      this.setLabelPositionOnTop()
    },
    deleteValue (index) {
      if (this.elements.length > 1) {
        this.elements.splice(index, 1)
      } else {
        this.elements = ['']
        this.isEmptyArray = true
        this.setLabelPositionByDefault()
      }
    },
    setLabelPositionOnTop () {
      const commonWrapper = this.$refs.array_field.firstChild.firstChild.firstChild
      const legendWrapper = commonWrapper.firstChild
      const labelWrapper = legendWrapper.nextSibling
      const label = labelWrapper.firstChild
      label.classList.add('v-label--active')
      const legend = legendWrapper.querySelector('legend').querySelector('span')
      legend.style.color = 'white'
      legend.style.fontSize = '12px'
      legend.style.textWrap = 'nowrap'
      legend.style.padding = '2px'
      const labelText = label.innerHTML
      legend.innerHTML = labelText
    },
    setLabelPositionByDefault () {
      const commonWrapper = this.$refs.array_field.firstChild.firstChild.firstChild
      const legendWrapper = commonWrapper.firstChild
      const labelWrapper = legendWrapper.nextSibling
      const label = labelWrapper.firstChild
      label.classList.remove('v-label--active')
      const legend = legendWrapper.querySelector('legend').querySelector('span')
      legend.innerHTML = '\u200b'
      legend.style.padding = 0
    },
    onBlur () {
      if (!this.isEmptyArray) {
        this.setLabelPositionOnTop()
      }
    }
  }
}
</script>

关键修改点

  1. 适配Vue3 v-model协议:将value prop改为modelValue,触发update:modelValue事件替代原input事件。
  2. 阻止attrs冲突:添加inheritAttrs: false,并通过计算属性filteredAttrs过滤掉v-model相关属性,避免传递给内部输入框。
  3. 保证响应式与类型安全:用浅拷贝初始化elements避免修改原prop,监听modelValue同步外部变化,过滤空值后再触发更新,确保返回始终是数组类型。
  4. 移除干扰逻辑:删除不必要的onInput方法和对应的事件绑定,避免单个字符串更新干扰外层v-model。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.04 06:35:54