本节目标:搞清 Vue 3 组合式 API 的类型从哪来、在哪生效、在哪失效。读完你能为
<script setup>组件写出精确的 props / emits / model 声明,能解释Ref<T>与模板自动解包为何对不上,能写出泛型 composable 与类型化的 provide / inject,并知道 vue-tsc 检查模板时哪些错误必现、哪些是盲区。
12.1 Vue 3 组合式 API 类型
上一章把 React 的类型拆成三层:组件契约、Hooks、Context。Vue 3 的组合式 API 也有对应的三层,但类型系统的作用点完全不同——React 的类型几乎全在函数签名上,而 Vue 有一半的类型落在模板里,并且由编译器接管。不先搞清这条管线,你会在「脚本里明明对、模板里却报红」的循环里反复打转。
12.1.1 类型管线:.vue 不是普通 TS 模块
.vue 单文件组件不是合法的 TypeScript 模块,tsc 读不懂它。Vue 的类型检查由 vue-tsc 完成,它基于 Volar 把 SFC 拆成虚拟 TS 文件(<script setup> 一块、模板一块、样式忽略),再交给 TS 引擎。所以第一件事是把脚本接对:
{
"scripts": {
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
"build": "vue-tsc --noEmit && vite build"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
include 里必须显式写上 **/*.vue,否则组件根本不进检查范围——这是「类型检查跑过了但什么都没查出来」的头号原因。Vite 只做转译不做类型检查(esbuild 直接剥掉类型),所以 vite build 通过不代表类型正确,CI 里必须单独跑一遍 vue-tsc。
| 工具 | 检查 .vue 模板 | 检查类型 | 用途 |
|---|---|---|---|
esbuild(Vite 内置) | 否 | 否 | 快速转译 |
tsc | 否 | 只看 .ts | 纯 TS 项目 |
vue-tsc | 是 | 是 | Vue 项目的类型门禁 |
12.1.2 ref 与 reactive:Ref<T> 与自动解包
import { ref, reactive } from 'vue';
const count = ref(0); // Ref<number>
const name = ref<string>(); // Ref<string | undefined>
const tags = ref<string[]>([]); // Ref<string[]>
const user = reactive({ id: 1, name: 'ada' });
// user 的类型:{ id: number; name: string }
两条规则必须记住。其一,脚本里访问 ref 要 .value,模板里自动解包(编译器把 {{ count }} 展开成 count.value),所以 count 的静态类型是 Ref<number>,而模板里可用的类型是 number。其二,reactive 只接受对象,返回深层响应式代理,其类型是 UnwrapNestedRefs<T>——对象里嵌套的 Ref 会被自动剥掉一层:
const state = reactive({ n: ref(0), list: [] as string[] });
state.n; // number —— 嵌套的 Ref 被解包了
state.list; // string[]
最危险的坑是解构 reactive 会丢响应式,但类型不报错。编译器完全沉默,运行时静默失效:
const { name: n } = user; // n 的类型是 string,不是 Ref<string>
n = 'bob'; // 改了,视图不更新,却没有任何类型错误
需要解构时改用 toRefs,状态库则用 storeToRefs:
import { toRefs } from 'vue';
const { name: nameRef } = toRefs(user); // Ref<string>
nameRef.value = 'bob'; // 正确触发更新
12.1.3 编译宏:不是函数,别当函数用
defineProps、defineEmits、defineExpose、defineModel、defineSlots、withDefaults 都是编译器宏:不需要 import,也不能赋值给变量后再调用,只能出现在 <script setup> 顶层。把它们放进普通函数或非 setup 的 <script> 里,会得到运行时的 ReferenceError: defineProps is not defined——因为编译器根本没机会替换它们。
// ✗ 错误:宏不能包在函数里
function buildProps() {
return defineProps<{ title: string }>(); // 编译期不替换 → 运行时 ReferenceError
}
IDE 里这几个名字能被识别,靠的是 vue/macros-global 类型声明,而不是真实的运行时函数。所以「补一个 import 就好了」这种直觉在宏上是错的。
12.1.4 defineProps:运行时声明与类型声明的双轨
类型声明模式写起来最自然:
<script setup lang="ts">
interface Props {
title: string;
count?: number;
tags?: string[];
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
tags: () => [], // 对象 / 数组默认值必须用工厂函数
});
props.title; // string
props.count; // number(withDefaults 之后不再是 number | undefined)
</script>
编译器会尽量从 TS 类型反推运行时 props 选项,好让非 TS 环境(例如 JS 写的父组件)也能收到校验。但反推有明确边界:
- 来自其他文件的
interface(如import type { User } from './types'),编译器无法静态展开,会退化成null,即不做任何运行时校验。 - 联合类型、泛型、
keyof、映射类型等复杂类型同样会被忽略。 - 所以类型声明模式擅长「类型精确」,运行时校验要靠对象字面量模式或外部 schema(见 《TypeScript编程实战》13.1 React Hook Form + Zod )。
对比对象字面量模式:
<script setup lang="ts">
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
});
// props.title: string | undefined —— required 不参与类型推导
</script>
| 模式 | 类型精度 | 运行时校验 | 复杂类型 |
|---|---|---|---|
类型声明 defineProps<Props>() | 高 | 弱(外部类型退化为 null) | 支持 |
对象字面量 defineProps({ ... }) | 低(可选字段推成 | undefined) | 强 | 需手写 |
12.1.5 defineEmits:具名元组写法
事件类型有两种写法:3.3 之前的调用签名((e: 'change', id: number): void)与 3.3 起的具名元组,推荐后者:
<script setup lang="ts">
const emit = defineEmits<{
change: [id: number];
submit: [payload: { name: string }];
}>();
</script>
参数写错会在编译期被抓,错误信息相当直白:
emit('change'); // ✗ Expected 2 arguments, but got 1
emit('change', '1'); // ✗ Argument of type 'string' is not assignable to parameter of type 'number'
emit('rename', 1); // ✗ Argument of type '"rename"' is not assignable to parameter of type '"change" | "submit"'
父组件监听时,回调参数也会被推导出来:
<template>
<UserForm @change="(id) => console.log(id)" @submit="onSubmit" />
</template>
12.1.6 defineModel:v-model 的类型来源
Vue 3.4 起,v-model 有了专门的宏:
<script setup lang="ts">
// 等价于 props: modelValue + emit('update:modelValue')
const model = defineModel<string>(); // ModelRef<string | undefined>
const count = defineModel<number>('count', { required: true }); // ModelRef<number>
const keyword = defineModel<string, 'trim'>(); // 允许 .trim 修饰符
</script>
ModelRef<T> 与 Ref<T> 的差别只在 required:非 required 的 model 读出来是 T | undefined,写 model.value = undefined 也合法。父组件 v-model 的类型完全由子组件的 defineModel<T> 决定——子组件把 T 写成 string,父组件传 number 就会在父组件那侧报错。这是「类型从子到父反向约束」的少数场景,值得单独记一笔。
12.1.7 computed 与 watch 的推导
import { computed, watch, ref } from 'vue';
const first = ref('Ada');
const last = ref('Lovelace');
const full = computed(() => `${first.value} ${last.value}`); // ComputedRef<string>
const upper = computed<string>(() => full.value.toUpperCase()); // 显式标注,防推成 string 字面量
computed 返回 ComputedRef<T>,是只读的。需要可写时用 getter / setter 形式,得到 WritableComputedRef<T>:
const firstName = computed({
get: () => full.value.split(' ')[0],
set: (v: string) => { first.value = v; },
});
watch 的类型陷阱在 immediate:开了 immediate 后首次回调的 oldValue 实际是 undefined。Vue 3.5 用重载把这件事表达进了类型,但 3.4 及以前回调签名里的 oldValue 仍是 T,需要自己处理:
watch(count, (value, oldValue) => {
// 首次(immediate)时 oldValue 是 undefined,类型上却写着 number
if (oldValue === undefined) return;
logger.debug('count changed', { from: oldValue, to: value });
}, { immediate: true });
侦听多个源时(watch([first, last], ([f, l], [pf, pl]) => ...)),回调参数是对应的元组,元素类型与源一一对应。
12.1.8 泛型 composable 的返回值
import { ref, shallowRef } from 'vue';
export function useAsync<T>(fn: () => Promise<T>) {
const data = ref<T | undefined>(undefined); // Ref<T | undefined>
const error = shallowRef<Error | undefined>(undefined);
const loading = ref(false);
async function run() {
loading.value = true;
error.value = undefined;
try {
data.value = await fn();
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e));
} finally {
loading.value = false;
}
}
return { data, error, loading, run } as const;
}
这里有个必须解释的细节:ref<T>()(无初值)在 Vue 3.5 之前的签名是 Ref<UnwrapRef<T> | undefined>,泛型 T 会被 UnwrapRef 加工一遍,导致给 data.value 赋 T 时类型对不上(T 含对象时尤其明显)。三种解法:
- 给初值:
ref<T | undefined>(undefined)——推荐,类型最干净。 - 断言:
ref<T>() as Ref<T | undefined>——能用,但把一个真实的类型缺陷藏了起来。 - 换
shallowRef<T>()——不递归解包,泛型友好,代价是嵌套修改不触发更新。
使用侧的类型自动流动:
const { data, loading } = useAsync(() => fetchJson<Post[]>('/api/posts'));
// data: Ref<Post[] | undefined>
返回对象加 as const 是为了让属性保持只读引用,避免消费方把 data 这个 ref 本身替换掉(.value 仍可写)。
12.1.9 provide / inject 的类型绑定
import type { InjectionKey, Ref } from 'vue';
import { provide, inject, ref } from 'vue';
export type Theme = 'light' | 'dark';
export const themeKey: InjectionKey<Ref<Theme>> = Symbol('theme');
// 祖先组件
const theme = ref<Theme>('light');
provide(themeKey, theme);
// 后代组件
const injected = inject(themeKey);
if (!injected) throw new Error('themeKey 未提供');
injected.value; // Theme
InjectionKey<T> 把 provide 与 inject 的类型绑在一起:provide 的第二个参数类型错了会报错,inject 取出来的类型也随键而定。inject 的返回类型恒为 T | undefined(除非给默认值),所以要判空或给默认值:
const theme = inject(themeKey, ref<Theme>('light')); // Ref<Theme>
默认值必须是完整类型的。写成 inject(themeKey, 'light') 会报错,因为键声明的类型是 Ref<Theme> 而不是 Theme。
12.1.10 模板检查:能力与边界
模板里的表达式由 vue-tsc 检查,v-for 的迭代变量、v-slot 的插槽 props、$event 都会被推导:
<script setup lang="ts">
import { ref } from 'vue';
interface Row { id: number; label: string }
const rows = ref<Row[]>([]);
function select(id: number) {}
</script>
<template>
<ul>
<li v-for="row in rows" :key="row.id" @click="select(row.id)">
{{ row.label }}
</li>
</ul>
</template>
把 select(row.id) 改成 select(row) 会立刻报 Argument of type 'Row' is not assignable to parameter of type 'number',且报错位置在模板那一行,错误信息里带 .vue 文件名与行号。反过来,$event 的类型来自 DOM 元素本身:
<input @input="(e) => console.log(e.target.value)" />
e 被推成 Event,e.target 是 EventTarget | null,.value 直接报错——这是新手最常见的模板错误。正确写法是收窄:
<input @input="(e) => console.log((e.target as HTMLInputElement).value)" />
盲区也要知道:模板里的类型错误只有 vue-tsc 能发现,浏览器和 Vite 都不会拦;v-html 的内容不参与检查;<component :is="name"> 在 name 是字符串时类型很弱;模板引用(<input ref="el">)在 3.5 之前需要手写 const el = ref<HTMLInputElement | null>(null),3.5 起可以用 useTemplateRef('el') 直接拿到正确类型。
12.1.11 常见坑速查
| 症状 | 原因 | 修法 |
|---|---|---|
| 解构后视图不更新 | reactive 解构丢响应式 | 用 toRefs / storeToRefs |
ref 无初值时 .value 赋值报错 | 泛型被 UnwrapRef 加工 | 用 ref<T | undefined>(undefined) |
| props 运行时校验不生效 | 外部导入类型退化为 null | 用对象字面量或 Zod |
defineProps is not defined | 宏被包进函数或非 setup | 移到 <script setup> 顶层 |
e.target.value 报错 | target 是 EventTarget | null | 收窄为 HTMLInputElement |
| 模板改了但类型没报错 | 未跑 vue-tsc | CI 加 vue-tsc --noEmit |
12.1.12 与本书其它章节的衔接
Vue 里的组件契约就是 props / emits / model,React 侧的对照写法见 《TypeScript编程实战》11.1 组件 props 与泛型组件
;组合式函数与自定义 Hook 的取舍见 《TypeScript编程实战》11.2 Hooks 类型与自定义 Hook
。全局状态建议收进 Pinia 并用 storeToRefs 取用,见 《TypeScript编程实战》11.3 Context 与状态管理(Zustand / RTK)
。本节写的 composable 应当有单元测试,见 《TypeScript编程实战》4.1 Vitest 单元测试
;类型门禁与覆盖率见 《TypeScript编程实战》4.3 类型测试与覆盖率门禁
。
站内延伸阅读:Vue 组合式 API 、Vue 与 TypeScript 实战 、Vue 3 响应式原理 、Vue 状态管理 、React / Vue / Svelte / Solid 对比 。
小结
Vue 3 的类型体系可以概括成一句话:脚本里是 Ref<T>,模板里是 T,两者之间的桥是编译器。
三件事值得带走。第一,.vue 的类型检查归 vue-tsc 管,vite build 通过不等于类型正确,CI 必须显式跑一遍,并把 **/*.vue 写进 include。第二,props / emits / model 的类型声明模式给出精确类型,但对来自其他文件的复杂类型会退化成「无运行时校验」,需要校验时用对象字面量或 schema 库兜底;inject 恒为可选,判空或给默认值。第三,泛型 composable 的返回值要用 ref<T | undefined>(undefined) 或 shallowRef<T>() 规避 UnwrapRef 的加工,否则会出现「明明赋的是 T,却提示不能赋给 T」的怪异错误。
下一节把视角从「组件内」抬到「框架层」:Next.js App Router 用同一套 TypeScript 承载服务端与客户端两种运行环境,Server Actions 与 Route Handlers 的类型边界、'use server' 的约束与序列化红线,会是那里的主角。
阅读导航:上一节:11.3 Context 与状态管理(Zustand / RTK) · 下一节:12.2 Next.js App Router 类型(Server Actions / Route Handlers) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。