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

核心依赖:

依赖作用
vueVue 3 核心
@vitejs/plugin-vueVite Vue 插件
vue-tscVue 类型检查工具
typescriptTS 编译器
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>
defineEmits 的类型签名看起来像函数重载。 每一行声明一个事件:第一个参数是事件名,后面的参数是事件携带的数据。写错了编辑器会立刻标红。

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>
可写 computed 的 set 参数类型需要手动标注。 因为 TypeScript 无法从 get 返回值反推 set 的参数类型,所以 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>
确保安装了 Volar(Vue Language Features)扩展。 没有它,<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>
inject 的返回值可能是 undefined。 如果祖先没有 provide 对应的 key,inject 返回 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、reactive、toRefs 类型推导

监听与计算

watch 回调参数类型、computed 自动推断

依赖注入

InjectionKey 类型安全、provide/inject 模式

高级特性

泛型组件、defineSlots、defineModel、路由类型

下一章

Vue 3 + TS 搞定了。下一章学习 状态管理——当组件之间需要共享数据时,如何优雅地管理全局状态。

1 / X