讲解

上一章说过,input 上的 v-model 是 :value + @input 的语法糖。自定义组件也支持 v-model——比如自己封装的搜索框、评分控件、开关——让父组件用起来和原生表单一样顺手。Vue 3.4 之前要用 modelValue prop + update:modelValue 事件手动实现这层糖;3.4 引入 defineModel 宏后,一行就搞定。

defineModel 在子组件里返回一个可读写的 ref:const model = defineModel(),子组件改 model.value 就自动向父组件同步。父组件照旧 <Rating v-model="score">,完全感知不到内部实现。想支持多个 v-model(比如对话框的 visible 和 form 数据分开绑)也简单:defineModel('visible')、defineModel('form') 各自独立,父组件写 v-model:visible、v-model:form。

defineModel 还支持修饰符和变换:defineModel({ type: Number, default: 0 }) 配类型和默认值;get/set 变换可以在父子之间转换数据形态(父存分、子显示元)。什么时候自己封装 v-model 组件?当一个输入控件在项目里出现第三次,或者它内部有复杂交互(搜索联想、级联选择)时,值得封。

示例

手动展开 v-model 的底层协议(modelValue + update:modelValue),理解 defineModel 替你做了什么(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

// 子组件内部:defineModel 展开后就是这层协议
function createRatingComponent(emit) {
  let modelValue = 0;
  return {
    get modelValue() {
      return modelValue;
    },
    setProps(v) {
      modelValue = v; // 父组件的 v-model 值流进来
    },
    rate(star) {
      emit('update:modelValue', star); // 子组件改值 = 发更新事件
    },
  };
}

// 父组件
const parent = { score: 3 };
const child = createRatingComponent((event, value) => {
  assert.strictEqual(event, 'update:modelValue');
  parent.score = value; // 父组件响应更新事件
});
child.setProps(parent.score);

assert.strictEqual(child.modelValue, 3); // 初始值流下来
child.rate(5); // 用户点了五星
assert.strictEqual(parent.score, 5); // 父组件状态被更新
child.setProps(parent.score);
assert.strictEqual(child.modelValue, 5);

// 多 v-model:visible 和 keyword 独立通道
const dlg = { visible: false, keyword: '' };
function bindModel(obj, key) {
  return {
    get: () => obj[key],
    set: (v) => (obj[key] = v),
  };
}
bindModel(dlg, 'visible').set(true);
bindModel(dlg, 'keyword').set('vue');
assert.deepStrictEqual(dlg, { visible: true, keyword: 'vue' });

console.log('评分: 3 → 用户点 5 星 → 父组件 score =', parent.score);
console.log('多 v-model 状态:', JSON.stringify(dlg));

真实组件(Vue 3.4+ 写法):

<script setup>
// Rating.vue
const model = defineModel({ type: Number, default: 0 });
</script>

<template>
  <span
    v-for="star in 5"
    :key="star"
    :class="{ on: star <= model }"
    @click="model = star"
  >
    ★
  </span>
</template>
<script setup>
// Parent.vue
import { ref } from 'vue';
import Rating from './Rating.vue';

const score = ref(3);
</script>

<template>
  <Rating v-model="score" />
  <p>当前评分:{{ score }} 星</p>
</template>

常见坑

  • 还在手写 modelValue/update:modelValue:Vue 3.4+ 直接用 defineModel,老写法只剩维护遗留代码时需要看懂。
  • 子组件直接改 modelValue prop:prop 只读,必须通过 emit(或 defineModel 的 ref)写回。
  • v-model 和 .value 层级混:defineModel 返回 ref,子组件模板里用 model,JS 里用 model.value
  • 给 v-model 传非响应式值:父组件 v-model="score" 的 score 必须是 ref/reactive 属性,否则只是单向传值。
  • 一个组件塞太多 v-model:超过两三个说明组件职责过重,考虑拆分或改用 props/emits 显式接口。

小结

组件 v-model 的协议是 prop + update 事件,defineModel 一行封装;多通道用 v-model:xxx。下一章讲组件的内容分发:插槽。