Skip to content
第 117 / 250 章前端⏱ 10 分钟阅读

第 117 章:Props 与 Emits 深入

学习目标

  • 深入掌握 Props 的类型、校验、默认值
  • 掌握 Emits 的各种用法与校验
  • 学会复杂的 props 模式与最佳实践
  • 区分 v-model 与 .sync 的替代方案

一、Props 完整指南

1.1 类型定义方式

写法 1:纯类型声明(推荐)

typescript
defineProps<{
  msg: string;
  count?: number;
  items: string[];
  user: { id: number; name: string };
  onChange?: (val: string) => void;
}>();

写法 2:运行时声明(支持默认值)

typescript
const props = defineProps({
  msg: String,
  count: {
    type: Number,
    default: 0
  },
  items: {
    type: Array as PropType<string[]>,
    default: () => []
  }
});

1.2 类型与默认值

typescript
// 用 withDefaults 给类型化的 props 添加默认值
interface Props {
  msg?: string;
  count?: number;
  items?: string[];
  visible?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  msg: 'Hello',
  count: 0,
  items: () => [],
  visible: false
});

1.3 PropType 复杂类型

typescript
import { PropType } from 'vue';

interface User {
  id: number;
  name: string;
}

const props = defineProps({
  user: Object as PropType<User>,
  users: Array as PropType<User[]>
});

二、Props 校验规则

2.1 支持的类型

typescript
defineProps({
  // 基本类型
  str: String,
  num: Number,
  bool: Boolean,
  arr: Array,
  obj: Object,
  date: Date,
  fn: Function,
  sym: Symbol,

  // 多个类型
  value: [String, Number],

  // 必填
  required: {
    type: String,
    required: true
  },

  // 默认值
  default: {
    type: Number,
    default: 100
  },

  // 对象/数组默认值必须用工厂函数
  list: {
    type: Array,
    default: () => []
  },
  obj: {
    type: Object,
    default: () => ({})
  },

  // 自定义校验
  age: {
    type: Number,
    validator: (val: number) => val >= 0 && val <= 150
  }
});

2.2 布尔转换

vue
<script setup>
defineProps<{ disabled?: boolean }>();
</script>

<template>
  <!-- 以下两种写法等价 -->
  <MyButton disabled />
  <MyButton :disabled="true" />
</template>

三、Props 高级用法

3.1 单向数据流

vue
<script setup lang="ts">
const props = defineProps<{ count: number }>();

// ❌ 不要直接修改 props
props.count++;  // 警告

// ✅ 拷贝到本地
const localCount = ref(props.count);

// ✅ 计算属性
const doubled = computed(() => props.count * 2);
</script>

3.2 父组件监听子组件 props 变化

vue
<!-- Parent.vue -->
<script setup>
import { ref, watch } from 'vue';
import Child from './Child.vue';

const childData = ref('');

watch(childData, (newVal) => {
  console.log('子组件数据变了', newVal);
});
</script>

<template>
  <Child v-model="childData" />
</template>

3.3 Props 解构

vue
<script setup lang="ts">
// ❌ 普通解构丢失响应式
const props = defineProps<{ count: number }>();
const { count } = props;  // 普通 number

// ✅ 用 toRefs
import { toRefs } from 'vue';
const props = defineProps<{ count: number; name: string }>();
const { count, name } = toRefs(props);
// count.value, name.value
</script>

<template>
  <!-- 模板中直接用 -->
  <p>{{ count }} - {{ name }}</p>
</template>

四、Emits 完整指南

4.1 基础语法

typescript
// 写法 1:类型签名(推荐)
const emit = defineEmits<{
  increment: [count: number];
  change: [value: string];
  submit: [];
}>();

// 写法 2:对象形式
const emit = defineEmits({
  increment: (count: number) => true,
  change: null
});

4.2 触发事件

typescript
emit('increment', 1);
emit('change', 'new value');
emit('submit');

4.3 事件校验

typescript
const emit = defineEmits({
  // 返回 false 时报警告
  submit: ({ id, name }: { id: number; name: string }) => {
    if (!id || !name) {
      console.warn('id 和 name 必填');
      return false;
    }
    return true;
  }
});

4.4 组件实例事件

vue
<!-- 父组件用 ref 访问子组件 -->
<script setup>
import { ref } from 'vue';
import Child from './Child.vue';

const childRef = ref<InstanceType<typeof Child> | null>(null);

const callChild = () => {
  childRef.value?.focus();
};
</script>

<template>
  <Child ref="childRef" />
</template>

五、组件 v-model 深入

5.1 v-model 原理

v-model="x" 实际上是 :modelValue="x" + @update:modelValue="x = $event" 的语法糖。

vue
<!-- 等价关系 -->
<Child v-model="x" />
<Child :modelValue="x" @update:modelValue="x = $event" />

5.2 自定义 v-model 名称

vue
<!-- Child.vue -->
<script setup lang="ts">
defineProps<{ search: string }>();
defineEmits<{ 'update:search': [value: string] }>();
</script>

<template>
  <input :value="search" @input="$emit('update:search', $event.target.value)" />
</template>
vue
<!-- Parent.vue -->
<Child v-model:search="searchText" />

5.3 多个 v-model

vue
<!-- Child.vue -->
<script setup lang="ts">
defineProps<{ name: string; age: number }>();
defineEmits<{
  'update:name': [value: string];
  'update:age': [value: number];
}>();
</script>
vue
<Child v-model:name="userName" v-model:age="userAge" />

5.4 v-model + 自定义修饰符

vue
<!-- Child.vue -->
<script setup lang="ts">
const props = defineProps<{
  modelValue: string;
  modelModifiers?: { capitalize?: boolean; upper?: boolean };
}>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();

const update = (e: Event) => {
  let value = (e.target as HTMLInputElement).value;
  if (props.modelModifiers?.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1);
  }
  if (props.modelModifiers?.upper) {
    value = value.toUpperCase();
  }
  emit('update:modelValue', value);
};
</script>

<template>
  <input :value="modelValue" @input="update" />
</template>
vue
<MyInput v-model.capitalize="text" v-model.upper="otherText" />

六、实战案例:通用表单组件

6.1 FormField.vue

vue
<script setup lang="ts">
interface Props {
  modelValue: string;
  label: string;
  type?: 'text' | 'password' | 'email';
  placeholder?: string;
  error?: string;
  required?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  type: 'text',
  required: false
});

const emit = defineEmits<{ 'update:modelValue': [value: string] }>();

const onInput = (e: Event) => {
  emit('update:modelValue', (e.target as HTMLInputElement).value);
};
</script>

<template>
  <div class="form-field">
    <label>
      {{ label }}
      <span v-if="required" class="required">*</span>
    </label>
    <input
      :type="type"
      :value="modelValue"
      :placeholder="placeholder"
      @input="onInput"
      :class="{ 'has-error': error }"
    />
    <p v-if="error" class="error">{{ error }}</p>
  </div>
</template>

6.2 使用

vue
<script setup lang="ts">
import { ref } from 'vue';
import FormField from './FormField.vue';

const username = ref('');
const password = ref('');
const errors = ref<Record<string, string>>({});

const submit = () => {
  errors.value = {};
  if (!username.value) errors.value.username = '用户名必填';
  if (password.value.length < 6) errors.value.password = '密码至少 6 位';
};
</script>

<template>
  <form @submit.prevent="submit">
    <FormField
      v-model="username"
      label="用户名"
      required
      :error="errors.username"
    />
    <FormField
      v-model="password"
      label="密码"
      type="password"
      required
      :error="errors.password"
    />
    <button type="submit">提交</button>
  </form>
</template>

七、组件通信模式

7.1 通信方式对比

7.2 选择建议

场景推荐方式
父传子(简单)Props
子传父(简单)Emits
双向绑定v-model
多层传递provide/inject
全局共享Pinia
临时事件mitt 等事件总线

八、常见错误

8.1 props 类型错误

vue
<!-- ❌ 父组件传错类型 -->
<Child :count="'10'" />  <!-- Child 期望 number -->

<!-- ✅ 修正 -->
<Child :count="10" />

8.2 解构丢失响应式

typescript
const props = defineProps<{ count: number }>();
const { count } = props;  // 丢失响应式

8.3 触发未声明的事件

typescript
// ❌ 没有声明就 emit
const emit = defineEmits<{ change: [v: string] }>();
emit('click');  // 警告:未声明的事件

九、本章小结

要点关键
Props 类型defineProps<{}>()defineProps({})
默认值withDefaults()
类型转换PropType<T>
EmitsdefineEmits<{}>() + emit()
v-model:modelValue + @update:modelValue
多 v-modelv-model:xxx
单向数据流props 只读,不能直接改

动手练习

  1. 复杂表单:实现一个用户注册表单,包含验证和错误提示
  2. 自定义 v-model:实现一个数字输入框,支持 .min .max 修饰符
  3. 事件校验:写一个 emits,带参数类型校验
  4. 父监听子变化:父组件监听子组件 props 变化并响应

推荐阅读


下一章第 118 章:插槽与作用域

本站基于 VitePress 构建 · 由 Codebook 团队维护