模块三 · 工程与体验
TypeScript 与 Vue:把组件契约写成类型
TS 在 Vue 里不是装饰,而是组件契约。这一节讲清 props/emits/model 的类型写法、模板 ref 与 provide 的类型、泛型组件,以及模板类型为什么会丢。
读完这一节,你能做到
- 用类型声明 props、emits 与 v-model,让调用方在编辑器里就能看到约束。
- 解释
ref、reactive、computed的类型推导规则,避免写出any。 - 给模板 ref、provide/inject 补上类型。
- 判断什么时候该用泛型组件,什么时候该收手。
一、为什么 Vue 项目需要 TypeScript
三个具体收益,不是"更规范"这种空话:
| 收益 | 场景 | 没有 TS 的后果 |
|---|---|---|
| 契约可查 | 别人用你的组件,不知道有哪些 props | 只能翻源码或跑起来试 |
| 重构安全 | 改一个字段名 | 全局搜索替换,漏一处线上才炸 |
| 模板校验 | {{ user.nmae }} | 拼错属性名,页面显示空白 |
关键在于最后一条:vue-tsc 会检查模板表达式,这是纯 tsc 做不到的。所以 Vue 项目的类型检查命令是 vue-tsc --noEmit(或 --build),不是 tsc。
二、项目配置的最小集合
| 文件 | 作用 | 常见错误 |
|---|---|---|
tsconfig.json | strict、paths、types | 没开 strict,等于白写 |
env.d.ts | 声明 .vue 模块与 import.meta.env | 缺了会报"找不到模块" |
vite.config.ts | 别名与插件 | 与 tsconfig 的 paths 不一致 |
vue-tsc | 模板类型检查 | 用 tsc 代替,模板错误查不出 |
env.d.ts 至少要有:
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
环境变量也要有类型,否则 import.meta.env.VITE_API_BASE 是 any:
interface ImportMetaEnv {
readonly VITE_API_BASE: string;
readonly VITE_APP_TITLE: string;
}
三、props:四种写法与选择
<script setup lang="ts">
// 1. 运行时声明:简单场景
const props = defineProps({ title: String, count: Number });
// 2. 类型声明:推荐
const props2 = defineProps<{
title: string;
count?: number;
tags?: string[];
}>();
// 3. 类型声明 + 默认值
const props3 = withDefaults(
defineProps<{ size?: "sm" | "md"; disabled?: boolean }>(),
{
size: "md",
disabled: false,
},
);
// 4. 外部类型复用
import type { User } from "@/types";
const props4 = defineProps<{ user: User; onSelect?: (id: string) => void }>();
</script>
选择顺序:优先类型声明;需要默认值时用 withDefaults;只有在写 JS 或需要运行时校验(如第三方组件库对外)时才用运行时声明。
三个细节:
- 类型声明是编译期的,运行时不校验。父组件传错类型不会报错,只会在类型检查时报。
- 可选 props 在模板里是
T | undefined,直接用会触发strict报错,要么给默认值,要么在模板里判空。 - 对象/数组默认值必须用工厂函数,类型声明下
withDefaults也要写成() => []。
四、emits:把事件也变成契约
const emit = defineEmits<{
(e: "submit", payload: { id: string }): void;
(e: "cancel"): void;
(e: "update:modelValue", value: string): void;
}>();
emit("submit", { id: "1" }); // 有类型提示
emit("submit"); // 编译报错
更简洁的 3.3+ 写法:
const emit = defineEmits<{
submit: [payload: { id: string }];
cancel: [];
}>();
事件名建议用 kebab-case 在模板里监听,defineEmits 里用 camelCase 或 kebab 都可以,但要全项目统一,否则会出现"监听了但没触发"。
五、v-model 的类型
// 单个 v-model(Vue 3.4+ 推荐)
const model = defineModel<string>({ required: true });
// 带默认值与修饰符
const count = defineModel<number>("count", { default: 0 });
// 需要区分修饰符时
const [title, modifiers] = defineModel<string>("title", {
set(value) {
return modifiers.trim ? value.trim() : value;
},
});
defineModel 编译后等价于"一个 xxx prop + 一个 update:xxx 事件"。父组件写 <Child v-model:title="t" />,子组件用 title.value = "x" 就能回写。
类型上要注意:defineModel<T>() 的 T 是值的类型,不是 ref 的类型;在模板里直接用 title,在 script 里用 title.value。
六、ref、reactive 与 computed 的类型
const count = ref(0); // Ref<number>
const user = ref<User | null>(null); // Ref<User | null>
const state = reactive({ list: [] as Order[] }); // 显式断言,避免 never[]
const total = computed(() => state.list.length); // ComputedRef<number>
四条经验:
ref(null)会推导成Ref<null>,后续赋值报错,必须写ref<User | null>(null)。reactive({ list: [] })的空数组是never[],要么断言,要么用ref<Order[]>([])。- 需要"可能为 undefined"时,用
ref<T>()(不带初始值),而不是ref(undefined as any)。 - 把
ref传给组合式函数时,参数类型写Ref<T>或MaybeRef<T>,不要写T。
七、模板 ref 与组件实例
<script setup lang="ts">
import { useTemplateRef, ref } from "vue";
import MyInput from "./MyInput.vue";
const inputEl = useTemplateRef<HTMLInputElement>("input");
const child = ref<InstanceType<typeof MyInput> | null>(null);
// 让父组件能拿到指定方法
// 子组件:defineExpose({ focus })
</script>
<template>
<input ref="input" />
<MyInput ref="child" />
</template>
三个易错点:
- 3.5 之前用
ref<HTMLInputElement | null>(null)加同名模板 ref;3.5 起推荐useTemplateRef,类型更准。 InstanceType<typeof Child>只能拿到defineExpose暴露的内容;没暴露就是空对象。- 模板 ref 在
onMounted之前是null,不要在setup顶层读。
八、provide/inject 的类型
// keys.ts
import type { InjectionKey, Ref } from "vue";
export const userKey: InjectionKey<Ref<User | null>> = Symbol("user");
// 父
provide(userKey, currentUser);
// 子
const user = inject(userKey, ref(null)); // 第二个参数给默认值,避免 undefined
用 InjectionKey<T> 而不是字符串 key,好处是:拼错 key 编译期报错,且注入值的类型自动推导。字符串 key 在大型项目里几乎必然出现"注入到另一个同名 key"的幽灵 bug。
九、泛型组件:什么时候值得
<script setup lang="ts" generic="T extends { id: string }">
defineProps<{ items: T[]; selected?: T }>();
defineEmits<{ select: [item: T] }>();
</script>
它解决了"表格/下拉框组件传 any[] 丢失类型"的问题。但泛型组件有三个代价:
- 模板里的类型推导会变复杂,报错信息更难读。
- 组件库对外发布时才值得,业务组件里泛型常被滥用。
- 泛型 + 默认值 + 插槽作用域三者组合时,编辑器可能推导不出。
判断标准:这个组件的类型关系是否能被调用方感知。能(表格列、树节点),用泛型;不能(普通布局组件),不要用。
十、常见坑
tsconfig没开strict,null与undefined一路穿透。- 只跑
tsc不跑vue-tsc,模板里的拼写错误全部漏掉。 defineProps用类型声明却忘了withDefaults,可选 props 在模板里到处报undefined。ref([])推导成never[],push时报类型错误。- 用
as any消掉错误,结果any沿调用链扩散,类型检查形同虚设。 - API 返回类型手写一份,接口改了不同步,运行时才发现字段没了。
十一、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 把一个 JS 组件改成
<script setup lang="ts">,props 用类型声明 +withDefaults。 - 给一个表单组件补
defineModel与defineEmits的类型,故意传错类型看报错位置。 - 用
InjectionKey改造一个字符串 key 的 provide/inject。 - 给
import.meta.env补类型声明,验证编辑器是否还有any。
② 让 AI 写同一题
把第 2 题(组件 props 类型)交给 AI,补一句 不许用 any,泛型要能推断出返回值。
③ 对比要盯的点
- 它用
any或as兜底了吗?出现了就等于类型没写。 defineProps它用运行时声明还是类型声明?两种混用会互相覆盖。- emit 的类型签名它写了吗?没写的话调用方传错参数也不会报错。
④ 一句话取舍
把它的 any 换成真实类型后,哪一处会立刻报错?
十二、自测
- 为什么 Vue 项目要用
vue-tsc而不是tsc? - 类型声明的 props 在运行时校验吗?
ref(null)为什么会推导成Ref<null>,怎么修?defineModel编译后等价于什么?- 泛型组件适合什么场景,代价是什么?
参考答案要点:要检查模板表达式;不校验,只在编译期;初始值为 null;一个 prop 加一个 update 事件;类型关系需被调用方感知,代价是推导复杂。
附:与接口类型的衔接
组件里的类型不是孤岛,它要跟接口契约对齐。三种做法,可靠性依次递增:
| 做法 | 说明 | 风险 |
|---|---|---|
| 手写 interface | 看接口文档抄一份 | 接口改了不同步,运行时才发现 |
| 从 OpenAPI 生成 | 用 openapi-typescript 生成 .d.ts | 需要后端维护规范 |
| 运行时校验 + 推导 | zod/valibot 定义 schema,z.infer 取类型 | 多一层校验成本 |
推荐组合:生成或手写类型做编译期约束,关键接口再用运行时校验做兜底。因为 TypeScript 不会校验接口实际返回什么,字段缺失时只会在渲染时报“Cannot read properties of undefined”。
import { z } from "zod";
const OrderSchema = z.object({
id: z.string(),
amount: z.number(),
createdAt: z.string(), // 接口返回的是字符串
remark: z.string().nullable(),
});
type Order = z.infer<typeof OrderSchema>;
async function fetchOrder(id: string): Promise<Order> {
const res = await request(`/api/orders/${id}`);
return OrderSchema.parse(res); // 在这里暴露契约不一致
}
四个容易踩的类型细节:
- 日期:接口给字符串,组件需要
Date时在请求层转换,不要让每个组件各转一次。 - 可选与可空:
remark?: string表示字段可能不存在,remark: string | null表示字段存在但值为空。两者在表单回填时行为不同。 - 枚举:后端加的枚举值前端不认识时,不要
switch直接抛错,要有 default 分支。 - 分页结构:用泛型
Page<T> = { list: T[]; total: number; page: number },不要为每种列表各写一份。
最后一条实践:不要在组件里 as Order。断言只是让编译器闭嘴,数据错的时候错误会推迟到更深的位置。宁可多写一个 parse,也不要让错误在 UI 层才爆。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解