《TypeScript编程实战》12.1 Vue 3 组合式 API 类型

Vue 3 的类型体验与 Vue 2 截然不同:ref 与 reactive 的自动解包、编译宏、defineProps 的双轨声明,常在 vue-tsc 下报错。本节从类型管线讲起,拆解 ref/reactive 解包规则、props/emits/model 声明、computed 与 watch 推导,以及泛型 composable 与 provide/inject 类型化。

本节目标:搞清 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 含对象时尤其明显)。三种解法:

  1. 给初值:ref<T | undefined>(undefined)——推荐,类型最干净。
  2. 断言:ref<T>() as Ref<T | undefined>——能用,但把一个真实的类型缺陷藏了起来。
  3. 换 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-tscCI 加 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) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes