Vue 3 与 TypeScript
Composition API + TS,类型推断几乎全自动
🎯 学习目标
学完本章,你将能够:
- 能够使用 defineProps 和 defineEmits 为 Vue 3 组件添加类型
- 能够正确标注 ref、reactive、computed 和 watch 的类型
- 能够使用 InjectionKey 模式实现类型安全的 provide/inject
- 能够定义泛型组件与 defineSlots 实现类型安全的插槽
- 能够配置路由类型安全与自定义指令类型
你有没有想过
Vue 的 ref 和 reactive 到底怎么写类型?defineProps 怎么声明?
Vue 3 的 Composition API 天生对 TypeScript 友好。大部分情况下你不用手写类型——编辑器会自动推断。但有些场景(比如空数组、复杂泛型)还是需要手动指定。
本章从项目创建到 Props、Emits、ref、reactive、组件暴露、路由类型,全面覆盖 Vue 3 + TS 的核心写法。
Part 1: 项目与推断
创建项目,体验 Vue 3 强大的自动类型推断
先搭好脚手架,再看看 Vue 3 的类型推断有多智能。你会发现大部分类型根本不用手写。
创建 Vite + Vue 3 项目
一行命令,TypeScript 开箱即用
$ npm create vite@latest my-vue-app -- --template vue-ts
$ cd my-vue-app && npm install && npm run dev
核心依赖:
| 依赖 | 作用 |
|---|---|
vue | Vue 3 核心 |
@vitejs/plugin-vue | Vite Vue 插件 |
vue-tsc | Vue 类型检查工具 |
typescript | TS 编译器 |
src/
├── main.ts # 入口
├── App.vue # 根组件
├── components/ # 组件
├── composables/ # 组合式函数
├── types/ # 类型定义
└── vite-env.d.ts # Vite 类型声明
自动类型推断
大部分类型,Vue 替你推断好了
<script setup lang="ts">
import { ref, computed, reactive, watch, onMounted } from "vue";
// ref —— 自动推断
const count = ref(0); // Ref<number>
const name = ref("张三"); // Ref<string>
const user = ref<User | null>(null); // 复杂类型需手动指定
// computed —— 自动推断返回类型
const double = computed(() => count.value * 2); // ComputedRef<number>
// reactive —— 自动推断
const state = reactive({
items: [] as string[], // 空数组需要类型断言
loading: false,
error: null as string | null,
});
// watch —— 自动推断被监听值的类型
watch(count, (newVal, oldVal) => {
// newVal 和 oldVal 都是 number
console.log(newVal);
});
onMounted(() => {
console.log("组件已挂载");
});
</script>
规律:有初始值的基本类型不需要手动写类型,空数组和复杂联合类型需要手动指定。
Part 2: Props 与 Emits
组件的输入和输出都需要类型
组件之间通信靠 Props 和 Emits。用 TypeScript 的接口来声明它们,编辑器会在你传错 props 或 emit 错误事件时立刻标红。
defineProps 类型
用泛型参数声明 Props,比运行时声明更优雅
<script setup lang="ts">
// 方式1:泛型参数(推荐)
interface Props {
title: string;
count?: number;
items: string[];
disabled?: boolean;
}
const props = defineProps<Props>();
// 带默认值:使用 withDefaults
const props = withDefaults(defineProps<Props>(), {
count: 0,
disabled: false,
});
</script>
对比运行时声明方式:
// 方式2:运行时声明(支持 validator,但不推荐)
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
});
defineEmits 类型
用接口声明事件名和参数类型
<script setup lang="ts">
// 定义事件接口
interface Emits {
(e: "update", value: string): void;
(e: "delete", id: number): void;
(e: "change", field: string, value: unknown): void;
}
const emit = defineEmits<Emits>();
// 使用
emit("update", "新值"); // OK
emit("delete", 42); // OK
// emit("update", 42); // 报错!value 应该是 string
// emit("unknown", ""); // 报错!未声明的事件
</script>
Part 3: ref 与 reactive
深入理解两种响应式变量的类型操作
ref 适合基本类型,reactive 适合对象。解构 reactive 丢失响应性时用 toRefs 补救。这节讲清楚每种情况下的类型写法。
ref 的类型
.value 是 ref 的灵魂,模板中自动解包
<script setup lang="ts">
import { ref } from "vue";
// ref 的类型操作
const count = ref(0); // Ref<number>
count.value = 42; // OK
// count.value = "hello"; // 报错!
// 模板中自动解包:count 直接使用(不需要 .value)
// <template>{{ count }}</template>
// ref 获取 DOM 元素
const inputRef = ref<HTMLInputElement | null>(null);
onMounted(() => {
inputRef.value?.focus();
});
</script>
// <template>
// <input ref="inputRef" />
// </template>
reactive 与 toRefs
对象类型的响应式,解构时不丢响应性
<script setup lang="ts">
import { reactive, toRefs } from "vue";
interface FormState {
username: string;
password: string;
remember: boolean;
}
const form = reactive<FormState>({
username: "",
password: "",
remember: false,
});
// toRefs 保持响应性——解构后仍然是 ref
const { username, password } = toRefs(form);
// username: Ref<string>
</script>
ref vs reactive 怎么选? 简单值用 ref,对象用 reactive。如果需要解构,用 toRefs 保持响应性。实际项目中 ref 用得更多,因为它更灵活。
Part 4: watch 与 computed
响应式数据的"监控"与"派生"
上一节学会了 ref 和 reactive 的类型写法。但数据变了怎么自动响应?派生数据怎么类型安全?这节讲 watch、watchEffect 和 computed 的类型用法。
watch 与 watchEffect
监听变化,自动推断新旧值类型
<script setup lang="ts">
import { ref, reactive, watch, watchEffect } from "vue";
const count = ref(0);
const name = ref("张三");
const state = reactive({ items: [] as string[] });
// 1. 监听 ref —— 自动推断 newVal 为 number
watch(count, (newVal, oldVal) => {
console.log(newVal.toFixed(2)); // OK: number 方法
});
// 2. 监听 reactive 的某个属性 —— 用 getter 函数
watch(
() => state.items.length,
(newLen) => {
// newLen: number
console.log(`列表长度变为 ${newLen}`);
}
);
// 3. 监听多个源
watch([count, name], ([newCount, newName]) => {
// newCount: number, newName: string
});
// 4. deep 选项 —— 深层监听 reactive 对象
watch(
() => state,
(newState) => {
// newState 的类型与 state 一致
console.log(newState.items);
},
{ deep: true }
);
</script>
watchEffect 进阶
自动追踪依赖 + 清理副作用
<script setup lang="ts">
import { ref, watchEffect } from "vue";
const userId = ref(1);
// watchEffect 自动追踪内部用到的响应式数据
// 不需要手动指定监听源
watchEffect(() => {
console.log(`当前用户: ${userId.value}`);
});
// 清理副作用:onCleanup 参数
// 比如取消上一次 fetch 请求
const searchQuery = ref("");
let controller: AbortController | null = null;
watchEffect((onCleanup) => {
controller?.abort();
controller = new AbortController();
onCleanup(() => {
controller?.abort();
});
fetch(`/api/search?q=${searchQuery.value}`, {
signal: controller.signal,
});
});
</script>
watch vs watchEffect 怎么选? 需要精确控制监听哪个值 → watch。只想"执行副作用并自动追踪依赖" → watchEffect。实际项目中 watch 用得更多,类型推断也更直观。
computed 类型
getter 自动推断,setter 需要了解签名
<script setup lang="ts">
import { ref, computed } from "vue";
const firstName = ref("张");
const lastName = ref("三");
// 1. 只读 computed —— 自动推断 ComputedRef<string>
const fullName = computed(() => `${firstName.value}${lastName.value}`);
// 2. 可写 computed —— 手动指定 get/set
const fullNameWritable = computed({
get: () => `${firstName.value}${lastName.value}`,
set: (val: string) => {
// val 的类型需要显式标注
firstName.value = val.charAt(0);
lastName.value = val.slice(1);
},
});
// 使用
fullNameWritable.value = "李四";
console.log(firstName.value); // "李"
console.log(lastName.value); // "四"
</script>
set(val: string) 中的 string 要自己写上。漏掉的话,val 会是 unknown。
Part 5: 模板与暴露
模板中的类型检查和子组件方法暴露
前面讲的都是 <script> 里的类型。但 <template> 里也有类型检查。另外,怎么让父组件安全地调用子组件的方法?这节给出答案。
Volar 模板检查
安装 Volar 扩展,模板里也有类型提示
在 tsconfig.json 中启用严格模板检查:
{
"vueCompilerOptions": {
"strictTemplates": true
}
}
启用后,模板中的 Props、事件、变量都会有类型检查:
<!-- 模板中的类型检查 -->
<template>
<UserCard
:name="userName" <!-- 检查 name 是否是 string -->
:age="userAge" <!-- 检查 age 是否是 number -->
@update="handleUpdate" <!-- 检查事件回调类型 -->
/>
</template>
<script setup lang="ts"> 的类型推断不会生效。如果你之前装过 Vetur,请先禁用它——两个插件会冲突。
defineExpose
显式暴露子组件的方法和属性
<!-- ChildComponent.vue -->
<script setup lang="ts">
const count = ref(0);
const reset = () => { count.value = 0; };
const increment = (n: number) => { count.value += n; };
// 暴露给父组件
defineExpose({ count, reset, increment });
</script>
<!-- ParentComponent.vue -->
<script setup lang="ts">
import ChildComponent from "./ChildComponent.vue";
// 用 InstanceType 获取子组件的暴露类型
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null);
const handleClick = () => {
childRef.value?.reset();
childRef.value?.increment(5);
console.log(childRef.value?.count); // number | undefined
};
</script>
<template>
<ChildComponent ref="childRef" />
<button @click="handleClick">操作子组件</button>
</template>
Part 6: 依赖注入
跨层级传数据,不用 props 一层层传
Props 只能父子传递。如果祖孙之间要传数据怎么办?provide / inject 就是解决方案。配合 TypeScript,注入的值也能有完整类型保护。
InjectionKey 模式
用 Symbol + InjectionKey 创建类型安全的注入键
// types/injection-keys.ts
import type { InjectionKey } from "vue";
// 定义注入键 —— 同时绑定类型
export const ThemeKey: InjectionKey<{
theme: string;
toggle: () => void;
}> = Symbol("theme");
export const UserKey: InjectionKey<{
name: string;
role: "admin" | "user";
}> = Symbol("user");
为什么用 InjectionKey? provide 和 inject 用字符串 key 的话没有类型。用 Symbol + InjectionKey<T>,inject 拿到的值自动带类型,编辑器也会提示。
provide 与 inject
祖先提供数据,后代注入数据
<!-- Provider.vue(祖先组件) -->
<script setup lang="ts">
import { provide, ref } from "vue";
import { ThemeKey } from "../types/injection-keys";
const theme = ref("light");
const toggle = () => {
theme.value = theme.value === "light" ? "dark" : "light";
};
// provide:键 + 值
provide(ThemeKey, { theme, toggle });
</script>
<!-- Consumer.vue(后代组件,任意深度) -->
<script setup lang="ts">
import { inject } from "vue";
import { ThemeKey } from "../types/injection-keys";
// inject 自动推断类型,但可能为 undefined
const themeCtx = inject(ThemeKey);
// 提供默认值,消除 undefined
const themeCtx2 = inject(ThemeKey, {
theme: "light",
toggle: () => {},
});
// 工厂函数默认值(避免每次创建新对象)
const themeCtx3 = inject(ThemeKey, undefined, true);
</script>
undefined。要么提供默认值,要么在使用时做空值检查:themeCtx?.toggle()。
Part 7: 泛型组件
写一个组件,适配多种数据类型
列表组件不应该绑定死 string[]。下拉菜单的选项可以是任意类型。Vue 3.3+ 支持 generic 属性,让组件像泛型函数一样灵活。
泛型组件语法
<script setup generic="T"> 声明类型参数
<!-- GenericList.vue -->
<script setup lang="ts" generic="T">
import { ref } from "vue";
interface Props {
items: T[];
selected?: T;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "select", item: T): void;
}>();
const selectedIndex = ref<number | null>(null);
function handleClick(item: T, index: number) {
selectedIndex.value = index;
emit("select", item);
}
</script>
<template>
<ul>
<li
v-for="(item, index) in items"
:key="index"
:class="{ active: index === selectedIndex }"
@click="handleClick(item, index)"
>
{{ item }}
</li>
</ul>
</template>
泛型约束与实战
限制 T 必须满足某些条件
<!-- 泛型下拉选择器 -->
<script setup lang="ts" generic="T extends { id: number | string }">
interface Props {
options: T[];
modelValue?: T;
labelKey?: keyof T;
}
const props = withDefaults(defineProps<Props>(), {
labelKey: "name" as keyof T,
});
const emit = defineEmits<{
(e: "update:modelValue", value: T): void;
}>();
function select(option: T) {
emit("update:modelValue", option);
}
</script>
<template>
<select @change="select" ...>
<option v-for="opt in options" :key="opt.id">
{{ opt[labelKey] }}
</option>
</select>
</template>
约束语法:generic="T extends { id: number | string }" 表示 T 必须有 id 属性。这样组件内部可以安全地访问 opt.id,编辑器不会报错。
defineSlots 类型
给插槽的 props 加上类型保护
<!-- DataTable.vue -->
<script setup lang="ts" generic="T">
interface Props {
items: T[];
columns: { key: keyof T; label: string }[];
}
defineProps<Props>();
// 定义插槽类型
const slots = defineSlots<{
default?(): void;
header?(props: { column: string }): void;
cell?(props: { item: T; key: keyof T }): void;
}>();
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns">
<slot name="header" :column="col.label">
{{ col.label }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items">
<td v-for="col in columns">
<slot name="cell" :item="item" :key="col.key">
{{ item[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
strictTemplates 后,父组件写 <template #cell="{ item, key }"> 时,item 和 key 的类型会自动推断。这是 Volar 提供的能力。
defineModel 类型
Vue 3.3+ 新方式,一行搞定双向绑定
<!-- TypedInput.vue -->
<script setup lang="ts">
// 基本用法:自动推断为 Ref<string>
const modelValue = defineModel<string>();
// 带默认值
const modelValue2 = defineModel<string>({ required: true });
// 命名 model(v-model:title="xxx")
const title = defineModel<string>("title");
// 带选项
const count = defineModel<number>("count", {
required: true,
default: 0,
});
</script>
<template>
<input v-model="modelValue" />
<input v-model="title" />
</template>
<!-- 父组件中使用 -->
<script setup lang="ts">
import { ref } from "vue";
import TypedInput from "./TypedInput.vue";
const name = ref("");
const title = ref("");
const count = ref(0);
</script>
<template>
<TypedInput v-model="name" />
<TypedInput v-model:title="title" />
<TypedInput v-model:count="count" />
</template>
defineModel 替代了以前的 props + emit("update:modelValue") 模式。 一行代码同时声明 props 和 emit,返回值是一个 ref,可以直接用 v-model 绑定。类型自动从泛型参数推断。
Part 8: 路由与指令
Vue Router 类型安全 + 自定义指令类型
路由是每个项目都会用到的。自定义指令虽然用得少,但写的时候类型也不能错。这节把路由和指令的类型一起讲完。
路由类型安全
useRoute 和 useRouter 的类型用法
// router/index.ts
import { createRouter, createWebHistory } from "vue-router";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: () => import("../views/Home.vue") },
{ path: "/users/:id", component: () => import("../views/UserDetail.vue"),
props: true },
],
});
export default router;
<script setup lang="ts">
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
// route.params 类型是 Record<string, string | string[]>
const userId = route.params.id as string;
// 类型安全的导航
router.push("/users/123");
router.push({ name: "UserDetail", params: { id: "123" } });
</script>
自定义指令类型
指令也能写类型,Directive<T> 了解即可
// directives/focus.ts
import type { Directive, DirectiveBinding } from "vue";
// Directive<HTMLElement, T> 中:
// 第一个泛型 = 指令绑定的元素类型
// 第二个泛型 = 绑定值的类型
export const vFocus: Directive<HTMLInputElement, boolean> = {
mounted(el, binding: DirectiveBinding<boolean>) {
// el 是 HTMLInputElement,可以访问 .focus()
// binding.value 是 boolean
if (binding.value !== false) {
el.focus();
}
},
};
// 更实用的例子:权限控制指令
export const vPermission: Directive<HTMLElement, string[]> = {
mounted(el, binding: DirectiveBinding<string[]>) {
const userPermissions = ["read", "write"]; // 从 store 获取
const required = binding.value;
if (!required.some((p) => userPermissions.includes(p))) {
el.style.display = "none";
}
},
};
<!-- 使用 -->
<script setup lang="ts">
// 局部注册
const vFocus = (await import("./directives/focus")).vFocus;
</script>
<template>
<input v-focus />
<!-- 传入 boolean 值 -->
<input v-focus="true" />
<!-- 传入 string[] 值 -->
<div v-permission="['admin']">管理员内容</div>
</template>
什么时候写自定义指令? 优先用组件。只有"直接操作 DOM"的场景才用指令,比如自动聚焦、权限隐藏、拖拽、长按。大部分业务逻辑用组件 + composable 就够了。
常见排错
<script setup lang="ts"> 语法。 配合 Volar 扩展,这是 Vue 3 + TS 项目的最佳实践。
问题一:ref([]) 的类型推断为 Ref<never[]>
原因:空数组无法推断元素类型,默认为 never[]。
修复:手动指定泛型 ref<string[]>([]),或用类型断言 ref([] as string[])。
问题二:Volar 报"组件上的属性不存在"
原因:strictTemplates 开启后,模板会严格检查 Props 类型。
修复:确认 Props 声明中包含该属性,或检查属性名是否拼写正确。
问题三:解构 reactive 对象后丢失响应性
原因:直接解构 reactive 对象,得到的是普通值,不是响应式的。
修复:用 toRefs 解构:const { a, b } = toRefs(state)。
问题四:watch 监听 reactive 对象不触发
原因:watch(state, callback) 默认是浅层比较,修改深层属性不会触发。
修复:加 { deep: true } 选项,或监听 getter 函数 watch(() => state.items, cb, { deep: true })。
问题五:inject 拿到的值类型是 undefined
原因:没有使用 InjectionKey<T>,或者用了字符串 key。
修复:用 Symbol + InjectionKey<T> 创建 key,配合 inject(key, defaultValue) 提供默认值。
问题六:泛型组件编译报错"generic 属性不支持"
原因:generic 是 Vue 3.3+ 特性,旧版本不支持。
修复:升级 vue 和 volar 到最新版本,确保 vue >= 3.3。
试一试
练习一
任务:创建一个 Counter.vue 组件,Props 包含 initialValue(number,默认 0),Emits 包含 change 事件(携带当前值)。用按钮实现加减操作。
<!-- 提示:使用 defineProps + defineEmits -->
<!-- 你的代码: -->
参考答案
<script setup lang="ts">
import { ref, watch } from "vue";
interface Props {
initialValue?: number;
}
const props = withDefaults(defineProps<Props>(), {
initialValue: 0,
});
interface Emits {
(e: "change", value: number): void;
}
const emit = defineEmits<Emits>();
const count = ref(props.initialValue);
function increment() {
count.value++;
emit("change", count.value);
}
function decrement() {
count.value--;
emit("change", count.value);
}
</script>
<template>
<p>{{ count }}</p>
<button @click="decrement">-1</button>
<button @click="increment">+1</button>
</template>
练习二
任务:创建一个 SearchInput.vue 组件,用 ref 获取 input DOM 元素,组件挂载后自动聚焦。同时通过 defineExpose 暴露 clear 方法。
<!-- 提示:ref<HTMLInputElement | null>(null) -->
<!-- 你的代码: -->
参考答案
<script setup lang="ts">
import { ref, onMounted } from "vue";
const inputRef = ref<HTMLInputElement | null>(null);
const value = ref("");
onMounted(() => {
inputRef.value?.focus();
});
function clear() {
value.value = "";
inputRef.value?.focus();
}
defineExpose({ clear });
</script>
<template>
<input ref="inputRef" v-model="value" />
</template>
练习三
任务:创建一个 ThemeProvider.vue 和 ThemeToggle.vue。Provider 用 provide 提供 { theme: Ref<string>, toggle: () => void },Toggle 用 inject 注入并切换主题。使用 InjectionKey 保证类型安全。
// 提示:先定义 InjectionKey
// export const ThemeKey: InjectionKey<...> = Symbol("theme")
参考答案
// types/keys.ts
import type { InjectionKey, Ref } from "vue";
export const ThemeKey: InjectionKey<{
theme: Ref<string>;
toggle: () => void;
}> = Symbol("theme");
<!-- ThemeProvider.vue -->
<script setup lang="ts">
import { provide, ref } from "vue";
import { ThemeKey } from "../types/keys";
const theme = ref("light");
const toggle = () => {
theme.value = theme.value === "light" ? "dark" : "light";
};
provide(ThemeKey, { theme, toggle });
</script>
<template><slot /></template>
<!-- ThemeToggle.vue -->
<script setup lang="ts">
import { inject, ref } from "vue";
import { ThemeKey } from "../types/keys";
// 默认值要用真正的 ref(),而不是 { value: ... } as any 强制绕过类型检查
const ctx = inject(ThemeKey, {
theme: ref("light"),
toggle: () => {},
});
</script>
<template>
<button @click="ctx.toggle">
当前: {{ ctx.theme }}
</button>
</template>
练习四
任务:创建一个泛型组件 GenericSelect.vue,接受 options: T[] 和 labelKey: keyof T,用 defineModel<T>() 实现选中值的双向绑定。约束 T 必须有 id 属性。
<!-- 提示:generic="T extends { id: number | string }" -->
参考答案
<script setup lang="ts" generic="T extends { id: number | string }">
interface Props {
options: T[];
labelKey: keyof T;
}
defineProps<Props>();
const selected = defineModel<T | undefined>();
</script>
<template>
<select v-model="selected">
<option :value="undefined">请选择</option>
<option v-for="opt in options" :key="opt.id" :value="opt">
{{ opt[labelKey] }}
</option>
</select>
</template>
📝 要点总结
本章核心知识点
组件定义
defineProps 泛型、defineEmits 类型、defineExpose
响应式类型
ref
监听与计算
watch 回调参数类型、computed 自动推断
依赖注入
InjectionKey
高级特性
泛型组件、defineSlots、defineModel、路由类型
下一章
Vue 3 + TS 搞定了。下一章学习 状态管理——当组件之间需要共享数据时,如何优雅地管理全局状态。