知

模块三 · 工程与体验

第 11 / 17 节18 分钟阅读型

TypeScript 与 Vue:把组件契约写成类型

TS 在 Vue 里不是装饰,而是组件契约。这一节讲清 props/emits/model 的类型写法、模板 ref 与 provide 的类型、泛型组件,以及模板类型为什么会丢。

读完这一节,你能做到

  1. 用类型声明 props、emits 与 v-model,让调用方在编辑器里就能看到约束。
  2. 解释 ref、reactive、computed 的类型推导规则,避免写出 any。
  3. 给模板 ref、provide/inject 补上类型。
  4. 判断什么时候该用泛型组件,什么时候该收手。

一、为什么 Vue 项目需要 TypeScript

三个具体收益,不是"更规范"这种空话:

收益场景没有 TS 的后果
契约可查别人用你的组件,不知道有哪些 props只能翻源码或跑起来试
重构安全改一个字段名全局搜索替换,漏一处线上才炸
模板校验{{ user.nmae }}拼错属性名,页面显示空白

关键在于最后一条:vue-tsc 会检查模板表达式,这是纯 tsc 做不到的。所以 Vue 项目的类型检查命令是 vue-tsc --noEmit(或 --build),不是 tsc。

二、项目配置的最小集合

文件作用常见错误
tsconfig.jsonstrict、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>

四条经验:

  1. ref(null) 会推导成 Ref<null>,后续赋值报错,必须写 ref<User | null>(null)。
  2. reactive({ list: [] }) 的空数组是 never[],要么断言,要么用 ref<Order[]>([])。
  3. 需要"可能为 undefined"时,用 ref<T>()(不带初始值),而不是 ref(undefined as any)。
  4. 把 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[] 丢失类型"的问题。但泛型组件有三个代价:

  • 模板里的类型推导会变复杂,报错信息更难读。
  • 组件库对外发布时才值得,业务组件里泛型常被滥用。
  • 泛型 + 默认值 + 插槽作用域三者组合时,编辑器可能推导不出。

判断标准:这个组件的类型关系是否能被调用方感知。能(表格列、树节点),用泛型;不能(普通布局组件),不要用。

十、常见坑

  1. tsconfig 没开 strict,null 与 undefined 一路穿透。
  2. 只跑 tsc 不跑 vue-tsc,模板里的拼写错误全部漏掉。
  3. defineProps 用类型声明却忘了 withDefaults,可选 props 在模板里到处报 undefined。
  4. ref([]) 推导成 never[],push 时报类型错误。
  5. 用 as any 消掉错误,结果 any 沿调用链扩散,类型检查形同虚设。
  6. API 返回类型手写一份,接口改了不同步,运行时才发现字段没了。

十一、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 把一个 JS 组件改成 <script setup lang="ts">,props 用类型声明 + withDefaults。
  2. 给一个表单组件补 defineModel 与 defineEmits 的类型,故意传错类型看报错位置。
  3. 用 InjectionKey 改造一个字符串 key 的 provide/inject。
  4. 给 import.meta.env 补类型声明,验证编辑器是否还有 any。

② 让 AI 写同一题

把第 2 题(组件 props 类型)交给 AI,补一句 不许用 any,泛型要能推断出返回值。

③ 对比要盯的点

  • 它用 any 或 as 兜底了吗?出现了就等于类型没写。
  • defineProps 它用运行时声明还是类型声明?两种混用会互相覆盖。
  • emit 的类型签名它写了吗?没写的话调用方传错参数也不会报错。

④ 一句话取舍

把它的 any 换成真实类型后,哪一处会立刻报错?

十二、自测

  1. 为什么 Vue 项目要用 vue-tsc 而不是 tsc?
  2. 类型声明的 props 在运行时校验吗?
  3. ref(null) 为什么会推导成 Ref<null>,怎么修?
  4. defineModel 编译后等价于什么?
  5. 泛型组件适合什么场景,代价是什么?

参考答案要点:要检查模板表达式;不校验,只在编译期;初始值为 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); // 在这里暴露契约不一致
}

四个容易踩的类型细节:

  1. 日期:接口给字符串,组件需要 Date 时在请求层转换,不要让每个组件各转一次。
  2. 可选与可空:remark?: string 表示字段可能不存在,remark: string | null 表示字段存在但值为空。两者在表单回填时行为不同。
  3. 枚举:后端加的枚举值前端不认识时,不要 switch 直接抛错,要有 default 分支。
  4. 分页结构:用泛型 Page<T> = { list: T[]; total: number; page: number },不要为每种列表各写一份。

最后一条实践:不要在组件里 as Order。断言只是让编译器闭嘴,数据错的时候错误会推迟到更深的位置。宁可多写一个 parse,也不要让错误在 UI 层才爆。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解