TypeScript 结合 Vue 3 与 Composition API

在 Vue 2 时代,如果我们想要在项目中集成 TypeScript 是比较麻烦的,而且集成之后总是感觉有点 “水土不服”。

但到了 Vue 3 时代,由于 Vue 3 源码本身就是使用 TypeScript 重写的,此时想要集成 TypeScript 就变得非常简单了。

提示: 如果小伙伴们想要系统地学习 Vue,也可以关注绿叶网的精品课:Vue 快速上手

响应式数据约束:如何给 ref()、reactive()、computed() 定义类型?

在 Vue 3 中,如果我们不为 ref()、reactive()、computed() 加上标注,虽然 TypeScript 也能对基础类型进行推导,但在面对复杂对象或异步初始值时,往往也会显得力不从心。

1. 给 ref() 传入泛型

对于简单类型,TypeScript 能自动推导出来。但如果初始值是 null,或者是一个接口,我们就必须使用泛型来约束,然后 TypeScript 才能正确推导。

示例 1:ref() 的类型标注

import { ref } from "vue";

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

// 自动推导
const count = ref(0);

// 使用泛型
const currentUser = ref<User | null>(null);

// 模拟异步获取数据
setTimeout(() => {
    currentUser.value = { id: 1001, name: "Jack" };
}, 1000);

分析:

对于 ref(0) 来说,TypeScript 能够自动推导其类型为 Ref<number>。但当我们使用 ref(null) 时,此时 TypeScript 就懵了:它不知道这个 null 以后会变成字符串、对象还是什么其他东西。于是 TypeScript 会将其推导为没有意义的 Ref<any>。

此时,我们可以在调用函数时显式传入泛型 <User | null>,用于明确告诉 TypeScript 它的数据边界。这样 TypeScript 就能正确推导出变量的最终返回类型为 Ref<User | null>,从而解决初始化为空值时的报错问题。

可能有小伙伴会问:“这里哪里使用了泛型呢?” 首先我们要清楚,Vue 底层是使用 TypeScript 写的,然后ref() 本质上是一个泛型函数,它的底层定义如下:

function ref<T>(value: T): Ref<T>

对于 ref<User | null>(null) 来说,紧跟在函数名后面的 <User | null> 并不是在 “定义泛型”,而是在 “传入具体的泛型参数”。也就是把 “User | null” 这个联合类型作为类型参数交给了 ref() 函数

2. reactive() 的类型推导

由于 reactive() 函数接收一个对象作为参数,因此 TypeScript 会自动根据你定义的接口来获取对象的内部结构。

示例 2:reactive() 与接口绑定

import { reactive } from "vue";

// 定义用户接口
interface User {
    username: string;
    age: number;
    isAdmin: boolean;
}

// 直接标注变量类型
const state: User = reactive({
    username: "Jack",
    age: 20,
    isAdmin: false
});

// 报错:不能将类型 “string” 分配给类型 “number”
// state.age = "25";

分析:

通过给响应式函数显式传入泛型,我们实际上是为数据建立了一层 “准入机制”。这样不仅能让代码在编写时就拥有自动提示,也能杜绝因为后端接口返回数据格式不符而导致的逻辑漏洞。

3. computed() 的类型推导

和 ref() 一样,大多数情况下 TypeScript 能够根据 computed() 内部的 return 语句自动推导出返回值类型。但在某些包含复杂分支逻辑的场景下,显式指定泛型可以让代码更加严谨。

示例 3:computed() 的类型标注

import { ref, computed } from "vue";

const userAge = ref(16);
const loginDays = ref(100);

// 场景 1:TS 自动推导 userLevel 为 ComputedRef<number>
const userLevel = computed(() => {
    return Math.floor(loginDays.value / 10);
});

// 场景 2:显式指定泛型(严格约束返回值为特定的联合类型)
const userStage = computed<"未成年" | "成年" | "老年">(() => {
    if (userAge.value >= 60) return "老年";
    if (userAge.value >= 18) return "成年";
    return "未成年";
});

分析:

在场景 2 中,我们通过 computed<"未成年" | "成年" | "老年"> 强行限制了返回值的类型边界。如果后续有其他同事修改了这个计算属性,不小心 return 了一个 "青铜",那么 TypeScript 编译器会立刻报错拦截。

父子组件通信:如何校验 defineProps 与 defineEmits?

在大型 Vue 项目中,组件之间的通信最容易变成 “重灾区”。如果父组件传错了参数,或者子组件触发了不存在的事件,传统的 JavaScript 开发只能靠肉眼去查。而在 TypeScript 的世界里,这一切都由 “契约” 说了算。

1. 使用宏函数定义类型化的 Props

Vue 3 提供的 defineProps 宏支持直接传入 TypeScript 接口。这种 “编译时类型检查” 比原生的 PropTypes 更加高效且严谨。

示例 4:精准标注 Props 并设置默认值

interface Props {
    title: string;
    likes?: number;                    // 可选属性
    status?: "active" | "disabled";    // 可选的联合类型
}

// Vue 3.5+ 推荐写法:直接解构并赋默认值,且保持响应式
const { title, likes = 0, status = "active" } = defineProps<Props>();

console.log("文章标题:", title);
console.log("点赞数量:", likes);

分析:

在传统的纯 TypeScript 泛型(defineProps<Props>())写法下,我们以前无法直接在接口里写默认值,通常需要借助 withDefaults() 这个额外的宏函数。

但在 Vue 3.5 之后,官方正式稳定了响应式 Props 解构特性。我们现在可以直接像原生 JavaScript 对象解构一样,在等号左侧为 likes = 0 和 status = "active" 赋予默认值。这不仅大幅减少了样板代码,而且解构出来的变量依然会保持响应式。一旦父组件传入的类型不对,TypeScript 编译器同样会立刻给出红色波浪线警告。

2. 类型化的 Emits 派发

同样地,对于用户卡片组件要派发给父级的事件(比如点击删除用户、更新用户角色),我们也可以通过 defineEmits 来进行严格限制。

示例 5:约束 UserCard 组件的事件流

const emit = defineEmits<{
    deleteUser: [id: number];
    updateRole: [id: number, newRole: string];
}>();

const handleDelete = () => {
    // TS 会自动校验参数类型
    emit("deleteUser", 1001);
};

分析:

通过这种方式,父子组件之间形成了一套透明的 “通讯协议”。一旦父组件传入的属性不符合要求,或者子组件派发了未定义的事件,TypeScript 编译器会立刻报错。这在多人协作开发时,能节省 50% 以上的沟通与调试时间。

3. 使用 defineModel 实现类型安全的双向绑定

在 Vue 3.4 之前,实现 v-model 需要同时写 defineProps 和 defineEmits,非常繁琐。现在,我们只需要一个 defineModel 宏即可搞定,并且它对 TypeScript 的支持极其完美。

示例 6:defineModel 的类型标注

<script setup lang="ts">
// 1. 基本用法:标注类型
const modelValue = defineModel<string>();

// 2. 进阶用法:标注类型的同时设置默认值或其他选项
const count = defineModel<number>("count", {
    default: 0,
    required: true
});

const updateData = () => {
    // TS 会自动提示 modelValue.value 必须是字符串
    modelValue.value = "Hello TypeScript";
    
    // TS 会自动提示 count.value 必须是数字
    count.value++;
};
</script>

分析:

通过直接给 defineModel<string>() 传入泛型,TypeScript 编译器就能瞬间明白这个双向绑定值的类型。当我们在组件内部尝试给 modelValue.value 赋一个数字,或者父组件使用 v-model 传入错误类型时,TypeScript 都会第一时间给出红色波浪线警告。这样大大地简化了之前分离编写 Props 和 Emits 的心智负担。

获取 DOM 与子组件:Template Refs 怎么做类型推导?

有时候我们需要直接操作 DOM 元素(比如调用 canvas 绘图),或者调用子组件的方法。这时候,ref() 指向的就不是普通数据,而是真实的 “节点” 或 “实例”。

子组件(UserEditModal.vue):

<script setup lang="ts">
const open = () => {
    console.log("弹窗已打开");
};

// 必须显式暴露,父组件才能访问到该方法
defineExpose({
    open
});
</script>

父组件(App.vue):

<template>
    <input type="text" ref="usernameInput" />
    <UserEditModal ref="userModal" />
</template>

<script setup lang="ts">
import { useTemplateRef, onMounted } from "vue";
import UserEditModal from "./UserEditModal.vue";

// 1. 标注 HTML 元素类型,传入模板中的 ref 名称
const usernameInputRef = useTemplateRef<HTMLInputElement>("usernameInput");

// 2. 标注子组件实例类型(使用 InstanceType 提取类型)
const modalRef = useTemplateRef<InstanceType<typeof UserEditModal>>("userModal");

onMounted(() => {
    // 安全调用原生 DOM 方法
    usernameInputRef.value?.focus();
    
    // 安全调用子组件暴露的方法
    modalRef.value?.open();
});
</script>

分析:

在 <script setup> 模式下,组件内部的数据和方法默认是封闭的。TypeScript 的 InstanceType 虽然能在编写时帮我们推导出子组件可能有哪些方法,但在代码真正运行的时候,子组件必须通过 defineExpose 明确 “对外开放” 这些方法,父组件才能成功调用。这就好比 TypeScript 给了你一张地图,但真正的门还需要子组件自己打开。

原生事件处理:如何给 Event 加上类型?

在 Vue 模板中绑定事件时,如果是普通的点击事件倒还好,但涉及到表单输入时,如何获取输入框的值往往会让 TypeScript 报错。

示例 7:为原生 DOM 事件标注类型

<template>
    <input type="text" @change="handleChange" />
</template>

<script setup lang="ts">
// 错误写法:参数 e 隐式具有 "any" 类型
// const handleChange = (e) => { ... }

// 正确写法:显式指定 e 为 Event 类型
const handleChange = (e: Event) => {
    // 使用 as 断言为具体的 HTMLInputElement
    const target = e.target as HTMLInputElement;
    console.log("用户输入的值:", target.value);
};
</script>

分析:

原生事件的回调参数 e 必须指定为 Event 类型。但 TypeScript 并不知道你点击的是一个 div 还是一个 input,因此直接写 e.target.value 会报错。

我们需要使用 as HTMLInputElement 进行类型断言,告诉TypeScript 编译器:“放心,我确定这是一个输入框元素”,从而安全地获取到 .value 属性。

提示: 更多关于 event 对象的使用,另请参阅:JavaScript event 对象

深层组件通信:Provide / Inject 的类型校验

当组件嵌套层级很深时,我们会使用 provide 和 inject。但在没有任何约束的情况下,注入的数据极容易丢失类型。Vue 3 提供了一个专门的 InjectionKey 接口来解决这个问题。

示例 8:使用 InjectionKey 约束注入类型

import { provide, inject, InjectionKey, ref, Ref } from "vue";

// 1. 定义一个强类型的 Key(通常放在单独的 ts 文件中导出)
const ThemeKey: InjectionKey<Ref<string>> = Symbol("theme");

// 父组件:提供数据
const themeColor = ref("dark");
provide(ThemeKey, themeColor);

// 深层子组件:注入数据
// 自动推导为 Ref<string> | undefined
const currentTheme = inject(ThemeKey);

// 提供默认值,避免注入 undefined
const safeTheme = inject(ThemeKey, ref("light"));

分析:

我们可以把 InjectionKey 想象成一把带有芯片的专属钥匙。只有用这把带有类型信息的钥匙去 provide 和 inject,TypeScript 才能完美地把数据的类型从祖先组件传递到孙子组件中,不让类型在中间环节断掉。

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号