WinddSnow

Vue-and-TypeScript-Best-Practices-Typed-Props-Emits-Composables

字数统计: 4.9k阅读时长: 22 min
2026/08/01

第114课:Vue 与 TypeScript 最佳实践——类型 Props、defineEmits 类型、ComponentPublicInstance

TypeScript 与 Vue 3 的深度结合,使得开发者可以在模板、组件 Props、事件、Composable 和 Pinia Store 中获得全链路的类型安全。然而,Vue 单文件组件(SFC)的 <script setup> 语法和编译宏(如 definePropsdefineEmits)与普通的 TypeScript 函数有所不同,许多开发者在类型标注上仍会踩坑——如何为 Props 提供复杂的联合类型和默认值?如何为 Emits 声明精确的事件参数?如何获取模板引用的组件实例类型?如何编写泛型 Composable 并保持类型推导?本节课将逐一拆解 Vue 3 + TypeScript 项目中的核心类型模式:Props 的运行时声明与类型声明、Emits 的函数签名式声明、模板引用的类型标注、defineExpose 的类型暴露、Composable 的泛型设计以及 Pinia 的类型推导。这些最佳实践将帮助你在 Vue 3 项目中彻底告别 any,享受编译器全程护航的开发体验。


1. Props 的类型声明与默认值

<script setup lang="ts"> 中,有两种声明 Props 类型的方式:运行时声明(对象语法)和纯类型声明(TypeScript 泛型语法)。两种方式各有适用场景,可以混合使用。

1.1 纯类型声明

使用 defineProps<PropsType>() 是最简洁的方式,适合不需要自定义验证器且默认值可以用 withDefaults 处理的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
interface UserCardProps {
name: string;
age: number;
email?: string;
role: 'admin' | 'user' | 'guest';
tags: string[];
config?: {
theme: 'light' | 'dark';
notifications: boolean;
};
}

const props = defineProps<UserCardProps>();
</script>

优点:代码简洁,类型推导完整(props.name 自动为 string),支持复杂嵌套对象和联合类型。
局限:无法为属性提供自定义 validator;如果所有属性都有默认值且无需运行时校验,这就是最佳方式。

1.2 使用 withDefaults 提供默认值

当部分属性是可选的且需要默认值时,使用 withDefaults(defineProps<Props>(), defaults)

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts">
interface ButtonProps {
type?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
}

const props = withDefaults(defineProps<ButtonProps>(), {
type: 'primary',
size: 'medium',
disabled: false,
});
</script>

withDefaults 的第二个参数接收一个对象,其键对应 Props 中的可选属性。对于引用类型(如对象、数组)的默认值,必须使用工厂函数返回(与运行时声明一致),避免多个组件实例共享同一引用:

1
2
3
4
withDefaults(defineProps<{ items?: string[] }>(), {
items: () => [], // ✅ 工厂函数
// items: [], // ❌ 所有实例共享同一个数组
});

1.3 运行时声明(需要自定义验证时使用)

如果你需要为 Props 提供自定义验证函数,或需要在运行时动态校验(例如从 API 获取合法值列表),使用对象语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script setup lang="ts">
import { PropType } from 'vue';

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

const props = defineProps({
user: {
type: Object as PropType<User>,
required: true,
validator(value: User) {
return value.age >= 0 && value.age <= 150;
},
},
status: {
type: String as PropType<'active' | 'inactive' | 'pending'>,
default: 'active',
},
count: {
type: Number,
default: 0,
},
});
</script>

PropType<T> 是 Vue 提供的工具类型,用于将 TypeScript 类型转换为 Vue 运行时可以识别的类型。由于 ObjectArray 构造函数本身没有泛型信息,必须使用 as PropType<T> 来保留类型推导。

选择原则

  • 无自定义验证需求 → 纯类型声明 + withDefaults
  • 需要自定义验证或运行时动态默认值 → 运行时声明(对象语法)。
  • 复杂项目 → 混用:核心业务组件用运行时声明以确保运行时安全,简单 UI 组件用纯类型声明以保持简洁。

2. defineEmits 的类型声明

Vue 3.3+ 推荐使用函数签名式声明defineEmits 提供类型。这种方式清晰地表达了事件名及其参数类型,并支持 TypeScript 的完整类型检查。

2.1 函数签名式声明(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
<script setup lang="ts">
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'submit', data: { name: string; age: number }): void;
(e: 'cancel'): void;
}>();

// 使用
emit('update:modelValue', 'hello');
emit('submit', { name: 'Alice', age: 25 });
// emit('submit', { name: 'Alice' }); // ❌ 类型错误:缺少 age
</script>

每个重载签名描述一个事件:(e: 'eventName', ...args): void。Vue 会根据调用时传入的事件名自动匹配对应的签名,检查参数类型。

2.2 简化语法(Vue 3.3+ 对象式)

1
2
3
4
5
6
7
<script setup lang="ts">
const emit = defineEmits<{
'update:modelValue': [value: string];
submit: [data: { name: string; age: number }];
cancel: [];
}>();
</script>

对象式语法更简洁,每个键是事件名,值是一个元组表示参数列表。它与函数签名式完全等价,选择哪种取决于个人风格偏好。

2.3 运行时验证(可选)

如果需要运行时验证事件参数,可以结合运行时声明:

1
2
3
4
5
6
7
<script setup lang="ts">
const emit = defineEmits({
submit: (payload: { name: string; age: number }) => {
return payload && typeof payload.name === 'string' && typeof payload.age === 'number';
},
});
</script>

与 Props 的运行时验证一样,返回值 false 仅在控制台发出警告,不阻止事件触发。


3. 模板引用(Template Refs)的类型标注

<script setup lang="ts"> 中,模板引用变量必须显式声明类型(因为初始值为 null,TypeScript 无法推导出具体元素类型)。

3.1 原生 DOM 元素引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
import { ref, onMounted } from 'vue';

// 声明类型为 HTMLInputElement | null
const inputRef = ref<HTMLInputElement | null>(null);

onMounted(() => {
// 此时 inputRef.value 为 HTMLInputElement
inputRef.value?.focus();
});
</script>

<template>
<input ref="inputRef" placeholder="输入内容" />
</template>

关键点

  • 初始值必须设为 null(因为 DOM 尚未挂载),类型标注包含 | null
  • 访问时使用可选链inputRef.value?.focus())或提前进行非空断言(仅在 onMounted 中)。

3.2 子组件实例引用

当引用一个 Vue 组件时,需要获取该组件的实例类型。Vue 提供了 InstanceType<typeof Component> 工具来获取组件实例的类型。结合 defineExpose,可以获得精确的暴露方法类型。

1
2
3
4
5
6
7
8
9
10
11
<!-- ChildModal.vue -->
<script setup lang="ts">
import { ref } from 'vue';

const visible = ref(false);

function open() { visible.value = true; }
function close() { visible.value = false; }

defineExpose({ open, close, visible });
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import ChildModal from './ChildModal.vue';

// 获取组件实例类型
const modalRef = ref<InstanceType<typeof ChildModal> | null>(null);

function handleOpen() {
modalRef.value?.open(); // 类型安全:open() 存在且类型正确
}
</script>

<template>
<button @click="handleOpen">打开弹窗</button>
<ChildModal ref="modalRef" />
</template>

InstanceType<typeof ChildModal> 的工作原理:

  • typeof ChildModal 获取组件的构造函数类型(它是一个 Vue 组件选项对象或 setup 函数的类型)。
  • InstanceType<...> 提取该组件实例的公共属性和方法——即通过 defineExpose 暴露的内容。如果组件没有 defineExpose,实例类型只包含 $el$emit 等内置属性。

4. Composable 的泛型设计与类型推导

Composable 函数是 Vue 3 逻辑复用的核心。通过 TypeScript 泛型,可以编写灵活且类型安全的 Composable,让调用方获得完整的类型推导。

4.1 泛型 Composable 示例:useList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// composables/useList.ts
import { ref, computed } from 'vue';

export function useList<T>(initialItems: T[] = []) {
const items = ref<T[]>(initialItems);

const count = computed(() => items.value.length);

function addItem(item: T) {
items.value.push(item);
}

function removeItem(predicate: (item: T) => boolean) {
items.value = items.value.filter(item => !predicate(item));
}

function updateItem(predicate: (item: T) => boolean, newItem: Partial<T>) {
items.value = items.value.map(item =>
predicate(item) ? { ...item, ...newItem } : item
);
}

return { items, count, addItem, removeItem, updateItem };
}

在组件中使用时,泛型 T 会被自动推导为传入的元素类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts">
import { useList } from '@/composables/useList';

interface Task {
id: number;
title: string;
completed: boolean;
}

// T 被推导为 Task
const { items, addItem, removeItem } = useList<Task>([
{ id: 1, title: 'Learn Vue', completed: false },
]);

// addItem 的参数类型是 Task,完全类型安全
addItem({ id: 2, title: 'Learn TypeScript', completed: false });
</script>

4.2 为 Composable 提供精确的返回类型

在复杂 Composable 中,推荐显式声明返回类型(或依赖 TypeScript 自动推导):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
interface UseFetchReturn<T> {
data: Ref<T | null>;
loading: Ref<boolean>;
error: Ref<string | null>;
refetch: () => Promise<void>;
}

export function useFetch<T>(url: string): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>;
const loading = ref(false);
const error = ref<string | null>(null);

async function refetch() {
loading.value = true;
error.value = null;
try {
const res = await fetch(url);
data.value = await res.json();
} catch (err) {
error.value = (err as Error).message;
} finally {
loading.value = false;
}
}

// 初始请求
refetch();

return { data, loading, error, refetch };
}

5. Pinia 的 TypeScript 集成

Pinia 与 TypeScript 的配合已在第 110、111 课详述。这里补充几个最佳实践。

5.1 使用 TypeScript 泛型定义 Store 的 State 类型

在 Setup Store 中,refreactive 自动推导类型,但有时需要为复杂状态定义接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// stores/cart.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}

export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([]);

const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0).toFixed(2)
);

function addItem(product: Omit<CartItem, 'quantity'>) {
const existing = items.value.find(item => item.id === product.id);
if (existing) {
existing.quantity += 1;
} else {
items.value.push({ ...product, quantity: 1 });
}
}

return { items, totalPrice, addItem };
});

5.2 在组件中获得完整的 Store 类型推导

1
2
3
4
5
6
7
8
<script setup lang="ts">
import { useCartStore } from '@/stores/cart';

const cart = useCartStore();
// cart.items 的类型自动推导为 CartItem[]
// cart.totalPrice 的类型为 string
// cart.addItem 的参数类型为 Omit<CartItem, 'quantity'>
</script>

无需任何额外类型标注——useCartStore() 的返回值已被 Pinia 完全类型化。


6. 模板中的类型检查与 vue-tsc

Vue 3 提供了 vue-tsc 命令行工具,用于对 .vue 文件的模板进行静态类型检查(类似于 tsc.ts 文件的检查)。它可以捕获模板中 Props 使用错误、事件参数不匹配、不存在的变量等类型问题。

1
npm install -D vue-tsc

package.json 中配置脚本:

1
2
3
4
5
{
"scripts": {
"typecheck": "vue-tsc --noEmit"
}
}

vue-tsc 会读取 tsconfig.json 并检查所有 .vue.ts.tsx 文件,输出类型错误但不生成编译产物。CI 流程中应包含此步骤,确保类型安全。

常见配置:在 tsconfig.json 中启用 "strict": true,并设置 "moduleResolution": "bundler"(配合 Vite 等打包工具)。Vue 3 的官方脚手架(create-vue)会为你生成这些配置。


7. 综合实战:一个完全类型化的用户管理模块

以下案例整合了 Props、Emits、模板引用、Composable 和 Pinia Store 的全链路类型标注。

1
2
3
4
5
6
7
// types/user.ts
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// stores/users.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { User } from '@/types/user';

export const useUserStore = defineStore('users', () => {
const users = ref<User[]>([]);
const loading = ref(false);

async function fetchUsers() {
loading.value = true;
try {
const res = await fetch('/api/users');
users.value = await res.json();
} finally {
loading.value = false;
}
}

function addUser(user: User) {
users.value.push(user);
}

function removeUser(id: number) {
users.value = users.value.filter(u => u.id !== id);
}

return { users, loading, fetchUsers, addUser, removeUser };
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<!-- UserTable.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import type { User } from '@/types/user';

// Props
const props = withDefaults(defineProps<{
users: User[];
loading?: boolean;
}>(), {
loading: false,
});

// Emits
const emit = defineEmits<{
select: [user: User];
delete: [id: number];
}>();

// 模板引用
const tableRef = ref<HTMLTableElement | null>(null);

onMounted(() => {
console.log('表格已挂载:', tableRef.value);
});
</script>

<template>
<div v-if="loading">加载中...</div>
<table v-else ref="tableRef">
<thead>
<tr>
<th>姓名</th>
<th>邮箱</th>
<th>角色</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.role }}</td>
<td>
<button @click="emit('select', user)">查看</button>
<button @click="emit('delete', user.id)">删除</button>
</td>
</tr>
</tbody>
</table>
</template>

类型安全要点

  • User 接口在 types/user.ts 中集中定义,Props、Emits 和 Pinia Store 均引用该类型,修改一处全局生效。
  • Props 使用纯类型声明 + withDefaults,提供默认值且保留类型推导。
  • Emits 使用对象式简化语法,事件参数类型精确匹配。
  • 模板引用使用 ref<HTMLTableElement | null>(null),确保 DOM 操作类型安全。

课后练习

一、概念自测(选择题 / 填空题)

  1. (单选)<script setup lang="ts"> 中为 Props 提供类型声明的最简洁方式是?
    A. 使用运行时声明 defineProps({ ... })
    B. 使用 defineProps<PropsType>() 纯类型声明。
    C. 使用 PropType<T> 工具类型。
    D. 使用 type Props = ... 后直接传给 defineComponent

  2. (单选) 要为模板引用获取一个 Vue 子组件的实例类型,应使用以下哪个 TypeScript 工具类型?
    A. typeof ChildComponent
    B. InstanceType<typeof ChildComponent>
    C. Ref<ChildComponent>
    D. ComponentPublicInstance<ChildComponent>

  3. (填空)withDefaults(defineProps<Props>(), defaults) 中,对于数组或对象类型的默认值,必须使用 ______ 返回,以避免多个组件实例共享同一引用。

  4. (多选) 关于 Vue 3 + TypeScript 的 defineEmits 类型声明,哪些方式是合法的?
    A. defineEmits<{ (e: 'submit', data: User): void }>()
    B. defineEmits<{ submit: [data: User] }>()
    C. defineEmits(['submit'])(无类型参数)
    D. defineEmits<{ submit: User }>()

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要创建一个完全类型化的 Vue 3 任务管理组件。要求如下:

  • 使用 TypeScript 定义 Task 接口(id: number, title: string, completed: boolean, priority: 'low' | 'medium' | 'high')。
  • 组件接收 tasks: Task[] 作为 Props(纯类型声明,无默认值)。
  • 组件通过 Emits 抛出 toggle(参数为 id: number)和 delete(参数为 id: number)事件。
  • 模板引用绑定到任务列表的 <ul> 元素,类型为 HTMLUListElement
  • 使用 defineExpose 暴露一个 scrollToTop 方法(内部实现为操作模板引用)。
  • 组件内部不管理自己的状态,完全受控于父组件。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 Vue 3 单文件组件。提示词中需明确指定 TypeScript 接口定义、Props/Emits 的类型声明方式、模板引用的类型标注以及 defineExpose 的使用。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请创建一个完全类型化的 Vue 3 任务管理组件。需要创建以下文件:

  1. src/types/task.ts:导出 Task 接口,包含 id: numbertitle: stringcompleted: booleanpriority: 'low' | 'medium' | 'high'
  2. src/components/TaskList.vue
    • 使用 <script setup lang="ts">
    • 导入 Task 接口。
    • Props:defineProps<{ tasks: Task[] }>()(纯类型声明)。
    • Emits:defineEmits<{ toggle: [id: number]; delete: [id: number] }>()(对象式简化语法)。
    • 模板引用:const listRef = ref<HTMLUListElement | null>(null);
    • defineExpose({ scrollToTop: () => { listRef.value?.scrollTo({ top: 0, behavior: 'smooth' }); } })
    • 模板:渲染 <ul ref="listRef">v-for 遍历 tasks,每项包含复选框(checked 绑定 task.completed@change 派发 toggle)、标题、优先级标签和删除按钮。
  3. 所有代码添加 JSDoc 注释,确保 vue-tsc 无错误。完成后列出所有文件内容。

四、面试真题与参考答案

题目(阿里前端面试题):

请解释 Vue 3 中 definePropsdefineEmits 在 TypeScript 下的类型声明方式,并对比“纯类型声明”和“运行时声明”的适用场景。为什么 Vue 3 需要 PropType<T> 这个工具类型?在什么情况下它是必需的?另外,InstanceType<typeof Component> 这个工具类型在模板引用中扮演什么角色?

参考答案

defineProps 有两种 TypeScript 声明方式:纯类型声明defineProps<PropsType>())和运行时声明(对象语法配合 PropType<T>)。纯类型声明更简洁,类型推导完整,适合无需自定义验证的常规场景。运行时声明允许使用 validator、自定义默认值工厂等运行时功能,适合需要动态校验或复杂默认值逻辑的场景。

defineEmits 的 TypeScript 声明推荐使用函数签名式(e: 'name', ...args): void)或对象式{ eventName: [argType] })语法。两者都能提供精确的事件参数类型检查,对象式是 Vue 3.3+ 引入的简化语法。

PropType<T> 是必需的,因为 Vue 运行时的 Props 验证依赖于构造函数(如 StringNumber)来标识类型。TypeScript 的 interface 和联合类型在编译后会被擦除,Vue 无法在运行时识别它们。PropType<T> 告诉 Vue:“这个属性的 TypeScript 类型是 T,请在运行时将其视为对应的构造函数类型”。对于自定义对象、数组和联合类型,必须使用 as PropType<T> 才能保留类型推导。

InstanceType<typeof Component> 用于从 Vue 组件对象中提取其实例类型。在模板引用中,ref<InstanceType<typeof ChildModal>> 可以获得子组件通过 defineExpose 暴露的方法和属性的类型。这个类型包含了 $el$emit 等内置属性和 defineExpose 暴露的内容,使得父组件对子组件的访问完全类型安全,无需类型断言。


课后练习答案

一、概念自测答案

  1. B

    • 解析:defineProps<PropsType>() 是纯类型声明的最简洁方式。A 是运行时声明,C 是运行时声明中的辅助工具,D 是 Vue 2 的写法。
  2. B

    • 解析:InstanceType<typeof Component> 提取组件实例类型。A 获取的是组件对象本身的类型;C 是 Ref 包装类型;D 是 Vue 的内部类型,一般用于声明而非提取。
  3. 工厂函数(或 () => []

    • 解析:对象/数组默认值必须使用工厂函数,避免多个组件实例共享同一引用。
  4. A、B、C

    • 解析:A 是函数签名式(正确),B 是对象式简化语法(正确),C 是无类型参数的运行时声明(正确,但丢失类型安全)。D 错误,{ submit: User } 不是合法的 Emits 类型声明——元组应使用 [User] 包裹参数。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请用 Vue 3 + TypeScript 创建一个任务列表组件。要求:

  • 定义 Task 接口:id, title, completed, priority。
  • Props: tasks: Task[](纯类型声明)。
  • Emits: toggle(id: number) 和 delete(id: number)(对象式简化语法)。
  • 模板引用绑定到
      ,类型为 HTMLUListElement。
    • defineExpose 暴露 scrollToTop 方法。
    • 模板渲染列表,复选框控制完成状态,删除按钮派发事件。
    • 输出完整单文件组件。”
CATALOG
  1. 1. 第114课:Vue 与 TypeScript 最佳实践——类型 Props、defineEmits 类型、ComponentPublicInstance
    1. 1.1. 1. Props 的类型声明与默认值
      1. 1.1.1. 1.1 纯类型声明
      2. 1.1.2. 1.2 使用 withDefaults 提供默认值
      3. 1.1.3. 1.3 运行时声明(需要自定义验证时使用)
    2. 1.2. 2. defineEmits 的类型声明
      1. 1.2.1. 2.1 函数签名式声明(推荐)
      2. 1.2.2. 2.2 简化语法(Vue 3.3+ 对象式)
      3. 1.2.3. 2.3 运行时验证(可选)
    3. 1.3. 3. 模板引用(Template Refs)的类型标注
      1. 1.3.1. 3.1 原生 DOM 元素引用
      2. 1.3.2. 3.2 子组件实例引用
    4. 1.4. 4. Composable 的泛型设计与类型推导
      1. 1.4.1. 4.1 泛型 Composable 示例:useList
      2. 1.4.2. 4.2 为 Composable 提供精确的返回类型
    5. 1.5. 5. Pinia 的 TypeScript 集成
      1. 1.5.1. 5.1 使用 TypeScript 泛型定义 Store 的 State 类型
      2. 1.5.2. 5.2 在组件中获得完整的 Store 类型推导
    6. 1.6. 6. 模板中的类型检查与 vue-tsc
    7. 1.7. 7. 综合实战:一个完全类型化的用户管理模块
    8. 1.8. 课后练习
      1. 1.8.1. 一、概念自测(选择题 / 填空题)
      2. 1.8.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.8.3. 三、Agent 模式下的提示词示例
      4. 1.8.4. 四、面试真题与参考答案
    9. 1.9. 课后练习答案
      1. 1.9.1. 一、概念自测答案
      2. 1.9.2. 二、AI 编程任务参考答案(提示词示例)