v-model 自定义

v-model 是 Vue 最常用的指令之一,用于表单元素的双向绑定。自定义组件也可以实现 v-model,让组件使用更加直观。

v-model 原理

表单元素

对于原生表单元素,v-model 是语法糖:

<input v-model="value">

<!-- 等同于 -->
<input :value="value" @input="value = $event.target.value">

自定义组件

对于自定义组件,v-model 同样是语法糖:

<my-component v-model="value"></my-component>

<!-- 等同于 -->
<my-component :value="value" @input="value = $event"></my-component>

基本实现

默认实现

默认情况下,v-model 使用 value prop 和 input 事件:

Vue.component('my-input', {
  props: ['value'],
  template: `
    <input 
      :value="value"
      @input="$emit('input', $event.target.value)"
    >
  `
})
<my-input v-model="message"></my-input>
<p>{{ message }}</p>

完整示例

Vue.component('custom-input', {
  props: {
    value: [String, Number],
    placeholder: String,
    disabled: Boolean
  },
  template: `
    <div class="custom-input">
      <input 
        :value="value"
        :placeholder="placeholder"
        :disabled="disabled"
        @input="handleInput"
        @focus="$emit('focus', $event)"
        @blur="$emit('blur', $event)"
      >
    </div>
  `,
  methods: {
    handleInput(event) {
      this.$emit('input', event.target.value)
    }
  }
})

model 选项

自定义 prop 和 event

使用 model 选项自定义 prop 和 event:

Vue.component('my-checkbox', {
  model: {
    prop: 'checked',
    event: 'change'
  },
  props: {
    checked: Boolean,
    disabled: Boolean
  },
  template: `
    <input 
      type="checkbox"
      :checked="checked"
      :disabled="disabled"
      @change="$emit('change', $event.target.checked)"
    >
  `
})
<my-checkbox v-model="isChecked"></my-checkbox>

<!-- 等同于 -->
<my-checkbox :checked="isChecked" @change="isChecked = $event"></my-checkbox>

为什么需要自定义

某些情况下,value prop 和 input 事件不合适:

  • 复选框:使用 checkedchange
  • 单选框:使用 checkedchange
  • 选择框:使用 valuechange

实战示例

开关组件

Vue.component('switch-button', {
  model: {
    prop: 'on',
    event: 'toggle'
  },
  props: {
    on: {
      type: Boolean,
      default: false
    },
    disabled: Boolean
  },
  template: `
    <button 
      class="switch"
      :class="{ on: on }"
      :disabled="disabled"
      @click="toggle"
    >
      <span class="switch-label">{{ on ? '开' : '关' }}</span>
    </button>
  `,
  methods: {
    toggle() {
      if (!this.disabled) {
        this.$emit('toggle', !this.on)
      }
    }
  }
})
<switch-button v-model="isOn"></switch-button>

评分组件

Vue.component('star-rating', {
  model: {
    prop: 'rating',
    event: 'rate'
  },
  props: {
    rating: {
      type: Number,
      default: 0
    },
    max: {
      type: Number,
      default: 5
    },
    readonly: Boolean
  },
  template: `
    <div class="star-rating">
      <span 
        v-for="i in max" 
        :key="i"
        class="star"
        :class="{ active: i <= rating }"
        @click="rate(i)"
        @mouseenter="hoverRating = i"
        @mouseleave="hoverRating = 0"
      >★</span>
    </div>
  `,
  data() {
    return {
      hoverRating: 0
    }
  },
  methods: {
    rate(value) {
      if (!this.readonly) {
        this.$emit('rate', value)
      }
    }
  }
})
<star-rating v-model="rating" :max="10"></star-rating>

颜色选择器

Vue.component('color-picker', {
  model: {
    prop: 'color',
    event: 'change'
  },
  props: {
    color: {
      type: String,
      default: '#000000'
    }
  },
  data() {
    return {
      colors: [
        '#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
        '#ffff00', '#ff00ff', '#00ffff', '#ff8800', '#8800ff'
      ]
    }
  },
  template: `
    <div class="color-picker">
      <div class="color-preview" :style="{ backgroundColor: color }"></div>
      <div class="color-list">
        <span 
          v-for="c in colors" 
          :key="c"
          class="color-item"
          :style="{ backgroundColor: c }"
          :class="{ active: c === color }"
          @click="$emit('change', c)"
        ></span>
      </div>
      <input 
        type="color" 
        :value="color"
        @input="$emit('change', $event.target.value)"
      >
    </div>
  `
})
<color-picker v-model="selectedColor"></color-picker>

滑块组件

Vue.component('range-slider', {
  model: {
    prop: 'value',
    event: 'input'
  },
  props: {
    value: {
      type: Number,
      default: 0
    },
    min: {
      type: Number,
      default: 0
    },
    max: {
      type: Number,
      default: 100
    },
    step: {
      type: Number,
      default: 1
    }
  },
  computed: {
    percentage() {
      return ((this.value - this.min) / (this.max - this.min)) * 100
    }
  },
  template: `
    <div class="range-slider">
      <input 
        type="range"
        :value="value"
        :min="min"
        :max="max"
        :step="step"
        @input="$emit('input', Number($event.target.value))"
      >
      <div class="slider-track">
        <div class="slider-fill" :style="{ width: percentage + '%' }"></div>
      </div>
      <span class="slider-value">{{ value }}</span>
    </div>
  `
})
<range-slider v-model="volume" :min="0" :max="100"></range-slider>

多选组件

Vue.component('multi-select', {
  model: {
    prop: 'selected',
    event: 'change'
  },
  props: {
    options: {
      type: Array,
      required: true
    },
    selected: {
      type: Array,
      default: () => []
    }
  },
  methods: {
    toggle(option) {
      const index = this.selected.indexOf(option.value)
      if (index > -1) {
        this.$emit('change', this.selected.filter(v => v !== option.value))
      } else {
        this.$emit('change', [...this.selected, option.value])
      }
    },
    isSelected(option) {
      return this.selected.includes(option.value)
    }
  },
  template: `
    <div class="multi-select">
      <div 
        v-for="option in options" 
        :key="option.value"
        class="option"
        :class="{ selected: isSelected(option) }"
        @click="toggle(option)"
      >
        {{ option.label }}
      </div>
    </div>
  `
})
<multi-select 
  v-model="selectedTags"
  :options="tagOptions"
></multi-select>

计数器组件

Vue.component('number-input', {
  model: {
    prop: 'value',
    event: 'input'
  },
  props: {
    value: {
      type: Number,
      default: 0
    },
    min: Number,
    max: Number,
    step: {
      type: Number,
      default: 1
    }
  },
  methods: {
    increase() {
      const newValue = this.value + this.step
      if (this.max === undefined || newValue <= this.max) {
        this.$emit('input', newValue)
      }
    },
    decrease() {
      const newValue = this.value - this.step
      if (this.min === undefined || newValue >= this.min) {
        this.$emit('input', newValue)
      }
    }
  },
  template: `
    <div class="number-input">
      <button @click="decrease" :disabled="min !== undefined && value <= min">-</button>
      <input 
        type="number"
        :value="value"
        :min="min"
        :max="max"
        :step="step"
        @input="$emit('input', Number($event.target.value))"
      >
      <button @click="increase" :disabled="max !== undefined && value >= max">+</button>
    </div>
  `
})
<number-input v-model="quantity" :min="1" :max="99"></number-input>

注意事项

prop 和 event 必须匹配

model 选项中的 propevent 必须与组件中的 props$emit 匹配:

// ✅ 正确
model: {
  prop: 'checked',
  event: 'change'
},
props: {
  checked: Boolean  // 匹配 prop
},
methods: {
  toggle() {
    this.$emit('change', newValue)  // 匹配 event
  }
}

// ❌ 错误:不匹配
model: {
  prop: 'checked',
  event: 'change'
},
props: {
  value: Boolean  // 不匹配
}

保留原有 prop

即使使用 model 自定义了 prop,原有的 prop 仍然存在:

Vue.component('my-checkbox', {
  model: {
    prop: 'checked',
    event: 'change'
  },
  props: {
    checked: Boolean,
    value: String  // 仍然可以接收 value
  }
})

小结

自定义 v-model 要点:

  1. 理解原理:v-model 是 props + events 的语法糖
  2. 默认约定:使用 value prop 和 input 事件
  3. 自定义配置:使用 model 选项自定义 prop 和 event
  4. 保持一致:prop 和 event 必须与组件实现匹配