在 Vue 3 应用中,如何使用 `TypeScript` 的类型系统,为 `v-model` 和 `props` 编写类型安全的组件?

各位靓仔靓女,欢迎来到今天的“Vue 3 + TypeScript:类型安全的组件炼成术”讲座现场!今天咱们不搞虚的,直接上干货,教大家如何用TypeScript武装你的Vue组件,让v-model和props都乖乖听话,再也不怕类型错误搞事情!

第一章:开胃小菜:TypeScript 和 Vue 3 的基情碰撞

首先,咱们得明白TypeScript和Vue 3这对CP为啥这么受欢迎。简单来说,TypeScript就是给JavaScript加了个“类型警察”,在编译阶段就帮你揪出类型错误,省得运行时才发现,那可就晚了。而Vue 3呢,本身就是用TypeScript重写的,对TypeScript的支持简直不要太好,简直是天生一对,地设一双。

第二章:Props:组件的“家当”,类型必须安排得明明白白

Props,组件的“家当”,是父组件传递给子组件的数据。TypeScript就是要确保这些“家当”的类型正确无误。

  • 基础类型:简单粗暴,直接定义

    最简单的场景,比如传递一个字符串、数字或者布尔值。

    // MyComponent.vue
    <script setup lang="ts">
    import { defineProps } from 'vue';
    
    const props = defineProps({
      name: {
        type: String,
        required: true,
      },
      age: {
        type: Number,
        default: 18,
      },
      isAdult: {
        type: Boolean,
        default: false,
      },
    });
    </script>
    
    <template>
      <div>
        <p>Name: {{ props.name }}</p>
        <p>Age: {{ props.age }}</p>
        <p>Is Adult: {{ props.isAdult }}</p>
      </div>
    </template>

    这里,defineProps 接收一个对象,每个属性对应一个prop。type 字段指定了prop的类型,required 表示是否是必需的,default 则定义了默认值。 注意,type的值必须是String, Number, Boolean, Array, Object, Date, Function, Symbol

  • 高级类型:对象、数组、自定义类型,一个都不能少

    如果prop是对象或数组,或者你想用更复杂的类型,那就需要用到TypeScript的类型定义了。

    // MyComponent.vue
    <script setup lang="ts">
    import { defineProps, PropType } from 'vue';
    
    interface User {
      id: number;
      name: string;
      email: string;
    }
    
    const props = defineProps({
      user: {
        type: Object as PropType<User>,
        required: true,
      },
      hobbies: {
        type: Array as PropType<string[]>,
        default: () => [],
      },
    });
    </script>
    
    <template>
      <div>
        <p>User Name: {{ props.user.name }}</p>
        <p>Hobbies: {{ props.hobbies.join(', ') }}</p>
      </div>
    </template>

    这里,我们定义了一个 User 接口,然后使用 PropType<User> 来指定 user prop的类型。对于数组,我们使用 PropType<string[]> 来指定 hobbies prop的类型。 注意,as PropType<T> 这种写法是告诉TypeScript,我们知道这个prop的类型是什么。 如果想默认值是空数组或者空对象,需要用函数返回,不然所有组件实例都会共享同一个数组/对象,修改一个实例,其他的都跟着变,这可不是我们想要的。

  • 联合类型和字面量类型:让类型更精确

    有时候,prop的类型可能不止一种,或者只能是几个固定的值,这时候就可以用到联合类型和字面量类型了。

    // MyComponent.vue
    <script setup lang="ts">
    import { defineProps, PropType } from 'vue';
    
    type Size = 'small' | 'medium' | 'large';
    
    const props = defineProps({
      size: {
        type: String as PropType<Size>,
        default: 'medium',
        validator: (value: string): boolean => {
          return ['small', 'medium', 'large'].includes(value);
        },
      },
      status: {
        type: [String, Number] as PropType<string | number>,
        default: 'pending',
      },
    });
    </script>
    
    <template>
      <div>
        <p>Size: {{ props.size }}</p>
        <p>Status: {{ props.status }}</p>
      </div>
    </template>

    这里,Size 是一个字面量类型,只能是 'small''medium''large' 中的一个。status 是一个联合类型,可以是字符串或数字。 注意,validator 函数可以用来验证prop的值是否合法,在开发阶段会发出警告。

  • 使用 defineProps 的泛型写法

    defineProps 还可以使用泛型写法,这种方式更加简洁,类型推断也更强大。

    // MyComponent.vue
    <script setup lang="ts">
    interface Props {
      name: string;
      age?: number;
      isAdult?: boolean;
    }
    
    const props = defineProps<Props>();
    </script>
    
    <template>
      <div>
        <p>Name: {{ props.name }}</p>
        <p>Age: {{ props.age }}</p>
        <p>Is Adult: {{ props.isAdult }}</p>
      </div>
    </template>

    这种写法直接定义了一个 Props 接口,然后将它作为 defineProps 的泛型参数。 可选的prop可以使用 ? 标记。

第三章:v-model:数据的“双向通道”,类型也要畅通无阻

v-model,数据的“双向通道”,是Vue中实现父子组件数据同步的重要机制。TypeScript当然也要保证这个通道的类型畅通无阻。

  • 基础 v-model:简单的数据同步

    最简单的v-model,就是同步一个字符串、数字或者布尔值。

    // MyInput.vue (子组件)
    <script setup lang="ts">
    import { defineProps, defineEmits, ref } from 'vue';
    
    const props = defineProps({
      modelValue: {
        type: String,
        default: '',
      },
    });
    
    const emit = defineEmits(['update:modelValue']);
    
    const inputValue = ref(props.modelValue);
    
    const handleChange = (event: Event) => {
      const target = event.target as HTMLInputElement;
      inputValue.value = target.value;
      emit('update:modelValue', target.value);
    };
    </script>
    
    <template>
      <input type="text" :value="inputValue" @input="handleChange" />
    </template>
    
    // ParentComponent.vue (父组件)
    <template>
      <MyInput v-model="message" />
      <p>Message: {{ message }}</p>
    </template>
    
    <script setup lang="ts">
    import { ref } from 'vue';
    import MyInput from './MyInput.vue';
    
    const message = ref('');
    </script>

    这里,子组件 MyInput 定义了一个 modelValue prop,用于接收父组件传递的值。同时,它还定义了一个 update:modelValue 事件,用于向父组件更新值。父组件使用 v-model="message"message 变量与子组件的 modelValue prop 和 update:modelValue 事件绑定起来,实现了数据的双向同步。

  • 自定义 v-model:更灵活的数据同步

    有时候,我们可能需要自定义v-model的prop和事件名,比如同步一个对象的某个属性。

    // MyComponent.vue (子组件)
    <script setup lang="ts">
    import { defineProps, defineEmits } from 'vue';
    
    interface Data {
      name: string;
      age: number;
    }
    
    const props = defineProps({
      title: {
        type: String,
        default: '',
      },
      modelValue: {
        type: Object as PropType<Data>,
        required: true,
      },
    });
    
    const emit = defineEmits(['update:modelValue', 'update:title']);
    
    const handleNameChange = (event: Event) => {
      const target = event.target as HTMLInputElement;
      emit('update:modelValue', { ...props.modelValue, name: target.value });
    };
    
    const handleTitleChange = (event: Event) => {
        const target = event.target as HTMLInputElement;
        emit('update:title', target.value);
    }
    </script>
    
    <template>
      <div>
        <input type="text" :value="props.modelValue.name" @input="handleNameChange" />
        <input type="text" :value="props.title" @input="handleTitleChange" />
      </div>
    </template>
    
    // ParentComponent.vue (父组件)
    <template>
      <MyComponent v-model:model-value="data" v-model:title="title"/>
      <p>Name: {{ data.name }}</p>
      <p>Age: {{ data.age }}</p>
      <p>Title: {{ title }}</p>
    </template>
    
    <script setup lang="ts">
    import { ref } from 'vue';
    import MyComponent from './MyComponent.vue';
    
    interface Data {
      name: string;
      age: number;
    }
    
    const data = ref<Data>({ name: 'John', age: 30 });
    const title = ref('Mr.');
    </script>

    这里,我们使用 v-model:model-value="data"v-model:title="title" 来指定v-model的prop和事件名。子组件需要定义相应的prop和事件,才能实现数据的双向同步。

  • 多 v-model:多个数据同步

    一个组件可以同时使用多个 v-model。

    // MyComponent.vue (子组件)
    <script setup lang="ts">
    import { defineProps, defineEmits } from 'vue';
    
    const props = defineProps({
      name: {
        type: String,
        default: '',
      },
      age: {
        type: Number,
        default: 0,
      },
    });
    
    const emit = defineEmits(['update:name', 'update:age']);
    
    const handleNameChange = (event: Event) => {
      const target = event.target as HTMLInputElement;
      emit('update:name', target.value);
    };
    
    const handleAgeChange = (event: Event) => {
      const target = event.target as HTMLInputElement;
      emit('update:age', Number(target.value));
    };
    </script>
    
    <template>
      <div>
        <input type="text" :value="props.name" @input="handleNameChange" />
        <input type="number" :value="props.age" @input="handleAgeChange" />
      </div>
    </template>
    
    // ParentComponent.vue (父组件)
    <template>
      <MyComponent v-model:name="name" v-model:age="age" />
      <p>Name: {{ name }}</p>
      <p>Age: {{ age }}</p>
    </template>
    
    <script setup lang="ts">
    import { ref } from 'vue';
    import MyComponent from './MyComponent.vue';
    
    const name = ref('');
    const age = ref(0);
    </script>

    每个 v-model 指令都绑定到不同的 prop 和事件。

第四章:Emit:组件的“汇报”,类型也要一丝不苟

Emit,组件的“汇报”,是子组件向父组件传递数据的手段。TypeScript当然也要确保这些“汇报”的类型正确无误。

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

// 使用类型字面量定义 emits
const emit = defineEmits<{
  (e: 'custom-event', payload: { id: number; name: string }): void
  (e: 'another-event', value: string): void
}>()

function doSomething() {
  emit('custom-event', { id: 123, name: 'foobar' })
  emit('another-event', 'hello')
}

// 另一种写法,使用数组定义
const emit2 = defineEmits(['custom-event', 'another-event'])

function doSomething2() {
  emit2('custom-event', { id: 123, name: 'foobar' }) // 不会报错,但类型检查较弱
  emit2('another-event', 'hello')
}
</script>
  • 使用泛型语法

    // MyComponent.vue
    <script setup lang="ts">
    import { defineEmits } from 'vue';
    
    interface Emits {
      (e: 'update:name', value: string): void
      (e: 'update:age', value: number): void
    }
    
    const emit = defineEmits<Emits>();
    
    const handleNameChange = (event: Event) => {
      const target = event.target as HTMLInputElement;
      emit('update:name', target.value);
    };
    
    const handleAgeChange = (event: Event) => {
      const target = event.target as HTMLInputElement;
      emit('update:age', Number(target.value));
    };
    </script>

    这种写法更加清晰,易于维护。

第五章:类型推断:让 TypeScript 自动搞定

TypeScript的类型推断能力非常强大,很多时候它可以自动推断出类型,省去我们手动声明的麻烦。

  • 利用 computedref 的类型推断

    // MyComponent.vue
    <script setup lang="ts">
    import { ref, computed } from 'vue';
    
    const count = ref(0); // TypeScript 会自动推断出 count 的类型是 Ref<number>
    
    const doubledCount = computed(() => count.value * 2); // TypeScript 会自动推断出 doubledCount 的类型是 ComputedRef<number>
    </script>
    
    <template>
      <div>
        <p>Count: {{ count }}</p>
        <p>Doubled Count: {{ doubledCount }}</p>
      </div>
    </template>

    这里,我们没有显式地声明 countdoubledCount 的类型,但是TypeScript会自动推断出来。

  • 利用函数返回值类型推断

    // MyComponent.vue
    <script setup lang="ts">
    function getMessage() {
      return 'Hello, world!'; // TypeScript 会自动推断出 getMessage 的返回值类型是 string
    }
    
    const message = getMessage(); // TypeScript 会自动推断出 message 的类型是 string
    </script>
    
    <template>
      <div>
        <p>{{ message }}</p>
      </div>
    </template>

    这里,我们没有显式地声明 getMessage 的返回值类型,但是TypeScript会自动推断出来。

第六章:实战演练:一个完整的例子

咱们来一个完整的例子,把上面讲的知识都用上。

// components/UserForm.vue
<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <label for="name">Name:</label>
      <input type="text" id="name" v-model="form.name" />
    </div>
    <div>
      <label for="email">Email:</label>
      <input type="email" id="email" v-model="form.email" />
    </div>
    <button type="submit">Submit</button>
  </form>
</template>

<script setup lang="ts">
import { reactive, defineEmits } from 'vue';

interface UserForm {
  name: string;
  email: string;
}

const emit = defineEmits<{
  (e: 'submit', user: UserForm): void
}>()

const form = reactive<UserForm>({
  name: '',
  email: '',
});

const handleSubmit = () => {
  emit('submit', { ...form });
};
</script>

// App.vue
<template>
  <UserForm @submit="handleUserSubmit" />
  <p>User Name: {{ user?.name }}</p>
  <p>User Email: {{ user?.email }}</p>
</template>

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

interface User {
  name: string;
  email: string;
}

const user = ref<User | null>(null);

const handleUserSubmit = (userData: User) => {
  user.value = userData;
};
</script>

这个例子展示了如何使用TypeScript来定义props、v-model和emit的类型,以及如何利用类型推断来简化代码。

第七章:疑难解答:常见问题和解决方案

  • 类型错误提示太多,眼花缭乱怎么办?

    • 仔细阅读错误提示,理解错误的含义。
    • 逐步排查,先解决最明显的错误。
    • 善用搜索引擎,查阅相关资料。
    • 如果实在解决不了,可以向社区求助。
  • 类型定义太繁琐,代码变得臃肿怎么办?

    • 合理利用类型推断,减少手动声明的类型。
    • 使用类型别名和接口,简化类型定义。
    • 将类型定义放在单独的文件中,提高代码的可读性。
  • 如何处理第三方库的类型定义?

    • 安装对应的 @types 包。
    • 如果找不到 @types 包,可以尝试自己编写类型定义。
    • 可以使用 any 类型来临时解决问题,但要尽量避免使用。

第八章:总结与展望

今天我们学习了如何使用TypeScript为Vue组件编写类型安全的props、v-model和emit。掌握这些技巧,可以大大提高代码的质量和可维护性,减少运行时错误的发生。当然,TypeScript的学习是一个持续的过程,希望大家在实践中不断探索,不断进步!

最后,给大家留个小作业:尝试将你现有的Vue组件用TypeScript重构一遍,看看能发现多少隐藏的bug!

今天的讲座就到这里,感谢大家的收听!咱们下期再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注