Vue 基础体系 · 第 13/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 与 TypeScript:组件类型、泛型、模板检查和 API 契约

在 Vue 3 项目中,TypeScript 的价值不只是给变量补充类型。更重要的是,它可以把组件之间的数据流表达成可检查的契约:

  • 父组件传给子组件的 Props 是否完整、类型是否正确;
  • 子组件触发的 Emits 是否使用了正确的事件名和参数;
  • v-model 的值与更新事件是否匹配;
  • 插槽参数、模板引用和组件公开方法是否有明确类型;
  • 一个泛型组件是否能在不同数据类型下保持类型安全;
  • 模板中的表达式是否真的经过 TypeScript 检查;
  • 来自 HTTP、存储或第三方库的运行时数据是否被错误地“假设”为某种类型。

Vue 的模板最终会被编译成 JavaScript。TypeScript 负责静态检查,而不是运行时校验。要建立可靠的组件 API,必须同时理解这两个边界:

外部运行时数据
    │
    ├─ JSON 解析、表单输入、用户操作、URL 参数
    │
    ▼
运行时校验与转换
    │
    ▼
应用状态
    │
    ▼
组件 Props / Emits / Slots / v-model
    │
    ▼
模板类型检查
    │
    ▼
编译后的 JavaScript

如果只写 TypeScript 类型而不做运行时校验,类型可能从一开始就是错误的;如果只依赖运行时行为而没有模板检查,组件重构时又容易出现大量隐蔽错误。


一、先明确 TypeScript 在 Vue 中检查什么

TypeScript 的类型系统主要在编译期间工作。下面的代码中,Useruser 的类型会被 TypeScript 使用,但类型本身不会出现在最终 JavaScript 中:

interface User {
  id: number
  name: string
}

const user: User = {
  id: 1,
  name: 'Ada',
}

编译后运行时并不会自动检查:

user.name = 123

如果这段代码绕过了 TypeScript,或者数据来自 JSON,JavaScript 仍然可能运行到这里。TypeScript 类型是静态约束,不是运行时验证器。

在 Vue 中,这个区别尤其重要:

interface User {
  id: number
  name: string
}

const response = await fetch('/api/user')
const user = await response.json() as User

as User 只是告诉 TypeScript“把这个值当成 User”。它不会确认返回值真的拥有数字类型的 id 和字符串类型的 name

因此,下面两种情况需要区分:

// 编译时类型断言:不会产生运行时检查
const user = responseData as User
// 运行时检查:需要实际执行验证逻辑
function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) {
    return false
  }

  const record = value as Record<string, unknown>

  return (
    typeof record.id === 'number' &&
    typeof record.name === 'string'
  )
}

类型谓词 value is User 的含义是:如果函数返回 true,TypeScript 可以在后续代码中把 value 缩小为 User

const data: unknown = await response.json()

if (!isUser(data)) {
  throw new Error('服务端返回了无效用户数据')
}

console.log(data.name) // data 在这里被缩小为 User

这里的 unknownany 更安全。any 会关闭大部分检查:

const value: any = await response.json()

value.notExist.call() // TypeScript 通常不会阻止

unknown 强制调用者先判断:

const value: unknown = await response.json()

// value.notExist.call() // 编译错误

if (typeof value === 'string') {
  console.log(value.toUpperCase())
}

组件 API 的类型设计应该尽量避免让 any 穿过边界。一个组件一旦把输入或输出声明成 any,父子组件之间的静态契约就会出现缺口。


二、组件 API 契约是什么

可以把一个 Vue 组件抽象成一个带多个边界的函数:

组件 = 输入 Props
      + 外部事件 Emits
      + 插槽 Slots
      + 双向绑定 v-model
      + 透传属性 Attrs
      + 对外暴露的实例方法

如果只把组件看成“接收 Props、渲染模板”,就会遗漏事件、插槽和实例 API。

更形式化地说,一个组件可以近似表示为:

C : (P, M, S, A) -> (V, E, X)

其中:

  • P:Props,父组件传入的数据;
  • Mv-model 对应的模型值;
  • S:Slots,父组件提供的渲染函数;
  • A:透传属性和监听器;
  • V:组件渲染出的视图;
  • E:组件触发的事件;
  • X:通过模板引用暴露给父组件的实例 API。

所谓 API 契约,就是约束这些输入和输出:

  1. 父组件传入的 Props 必须满足 P 的类型;
  2. 子组件触发事件时,事件名和参数必须属于 E
  3. 插槽接收的参数必须和 S 的声明一致;
  4. v-model 的值和更新事件必须符合约定;
  5. 父组件只能调用 X 中明确暴露的方法;
  6. 未声明的属性是否透传到根节点,必须符合组件实现和使用预期。

这个模型可以帮助解释一个常见问题:某个 Prop 类型写得很严格,并不意味着整个组件 API 已经严格。一个组件可能 Props 很安全,但 Emits 使用了字符串拼写,Slots 没有类型,或者通过 defineExpose 暴露了未声明的内部状态。


三、defineProps:声明组件输入

<script setup> 中,defineProps 是编译器宏。它不需要导入,编译后会被 Vue 编译器转换成组件的 Props 配置和对应的访问逻辑。

3.1 类型声明方式

最直接的方式是使用基于 TypeScript 类型的声明:

<script setup lang="ts">
interface User {
  id: number
  name: string
  disabled?: boolean
}

const props = defineProps<{
  user: User
  dense?: boolean
}>()

console.log(props.user.name)
</script>

<template>
  <article :class="{ dense }">
    <h2>{{ user.name }}</h2>
    <span v-if="user.disabled">不可用</span>
  </article>
</template>

这里的含义是:

  • user 是必填 Prop;
  • dense 是可选 Prop;
  • user 必须符合 User
  • <script setup> 模板中,顶层绑定会自动暴露给模板,因此可以直接使用 userdense

父组件中的错误会被模板检查工具报告:

<UserCard
  :user="{ id: '1', name: 'Ada' }"
  :dense="true"
/>

id 声明为 number,但这里传入了 string,因此应当报错。

3.2 运行时声明与类型声明的区别

也可以使用运行时声明:

import { PropType } from 'vue'

const props = defineProps({
  user: {
    type: Object as PropType<User>,
    required: true,
  },
  dense: {
    type: Boolean,
    default: false,
  },
})

这种方式直接提供了 Vue 运行时可以使用的构造器和校验信息。

两种方式的职责不同:

写法 主要优势 主要边界
defineProps<Type>() TypeScript 类型表达自然,适合复杂类型 复杂运行时校验能力有限,类型会被擦除
defineProps({ ... }) Vue 运行时能获得 requireddefault、构造器等信息 复杂 TypeScript 类型表达更繁琐

在现代 Vue 编译器中,基于类型的 Props 声明会尝试从类型中推导部分运行时 Props 信息,但这不是完整的运行时数据验证。尤其是接口、条件类型和外部复杂类型,不能简单理解为会被转换成完整的运行时校验器。

如果数据来源不可信,应该在进入组件之前或组件边界处进行显式校验,而不是依赖 defineProps 替代后端数据验证。


四、可选 Props、默认值与空值语义

“可选”并不总是等同于“有默认值”。

const props = defineProps<{
  pageSize?: number
}>()

这里 pageSize 可能是:

number | undefined

如果组件内部要求它始终是数字,就需要提供默认值或显式处理:

const props = withDefaults(
  defineProps<{
    pageSize?: number
  }>(),
  {
    pageSize: 20,
  },
)

props.pageSize // number

withDefaults 的作用是让 TypeScript 知道默认值会填补可选字段。它并不是把传入的非法值转换成合法值。例如:

<Pagination :page-size="'20'" />

字符串并不会因为存在默认值而自动变成数字。

还要区分 undefinednull

const props = defineProps<{
  avatarUrl?: string | null
}>()

这里允许三种状态:

  • 没传:undefined
  • 明确传空:null
  • 有 URL:string

如果业务上只允许“有值或没有值”,可以只选择一种表示方式,避免组件内部同时处理两套空值语义。


五、defineEmits:声明组件输出

Props 是父组件传入的数据,Emits 是子组件向外发送的事件。事件契约至少包括事件名和参数列表。

<script setup lang="ts">
const emit = defineEmits<{
  save: [title: string]
  cancel: []
  delete: [id: number, reason?: string]
}>()

function save() {
  emit('save', '新标题')
}

function cancel() {
  emit('cancel')
}
</script>

这段代码具有以下约束:

emit('save', '新标题') // 正确
emit('cancel')         // 正确
emit('delete', 10)     // 正确,reason 可选

// emit('save', 123)   // 错误
// emit('cancel', 1)   // 错误
// emit('unknown')     // 错误

Vue 3.3 及更高版本支持这种基于元组的 Emits 类型写法。较早版本也常见下面的写法:

const emit = defineEmits<{
  (event: 'save', title: string): void
  (event: 'cancel'): void
}>()

二者表达的是同一类契约;项目应根据实际 Vue 版本选择语法。

父组件监听事件时,模板检查工具也可以检查事件参数:

<Editor
  @save="handleSave"
  @cancel="handleCancel"
/>
function handleSave(title: string) {
  console.log(title.toUpperCase())
}

function handleCancel() {
  console.log('cancelled')
}

如果把 handleSave 声明成不接受参数,是否报错还与函数类型兼容规则和模板检查工具的具体处理有关。因此,不应把“回调函数少写了参数”作为完整的事件契约验证。更可靠的错误通常是事件名错误、参数数量错误或参数类型明显不匹配。

Emits 不是事件总线

组件事件只用于子组件向直接父组件报告状态变化:

子组件 emit
    ↓
直接父组件监听

它不会自动像全局事件总线一样被任意组件接收。跨层级通信应使用明确的状态管理、依赖注入或其他数据流方案,而不是把组件 Emits 当作全局消息系统。


六、v-model 是一组 Props 与 Emits 的组合

组件上的:

<CustomInput v-model="name" />

在默认模型名下,等价于近似:

<CustomInput
  :model-value="name"
  @update:model-value="name = $event"
/>

因此,组件要支持默认 v-model,通常需要同时声明:

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function updateValue(event: Event) {
  const target = event.target as HTMLInputElement
  emit('update:modelValue', target.value)
}
</script>

<template>
  <input
    :value="modelValue"
    @input="updateValue"
  >
</template>

父组件:

<script setup lang="ts">
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'

const name = ref('Ada')
</script>

<template>
  <CustomInput v-model="name" />
</template>

数据流是:

父组件 name
    │
    ├─ :model-value
    ▼
子组件 props.modelValue
    │
    ├─ 用户输入
    ▼
emit('update:modelValue', newValue)
    │
    ▼
父组件 name 更新

Vue 3.4 引入了 defineModel 宏,可以简化这个模式:

<script setup lang="ts">
const model = defineModel<string>()
</script>

<template>
  <input v-model="model">
</template>

defineModel 是版本敏感能力。使用它前,应确认项目 Vue 版本和工具链支持情况。为了兼容旧版本,显式声明 modelValueupdate:modelValue 仍然是稳定、清晰的写法。

命名模型:

<FilterPanel v-model:keyword="keyword" />

对应的组件契约近似为:

const props = defineProps<{
  keyword: string
}>()

const emit = defineEmits<{
  'update:keyword': [value: string]
}>()

如果组件只更新了本地变量,却没有发送 update:modelValue,父组件状态就不会改变。模板表面上可能没有错误,但运行时数据流已经断裂。


七、泛型解决什么问题

泛型不是“让类型更复杂”,而是表达多个值之间的类型关系。

普通类型声明只能表达:

function first(value: unknown[]): unknown {
  return value[0]
}

调用者无法知道返回值和输入数组元素之间的关系。

泛型可以表达:

function first<T>(value: T[]): T | undefined {
  return value[0]
}

推导过程是:

输入:number[]
确定:T = number
返回:number | undefined
输入:User[]
确定:T = User
返回:User | undefined

泛型的核心不是“允许任意类型”,而是“对某个具体类型保持一致的关系”。

7.1 Vue 组件中的泛型

假设要实现一个列表组件:

  • 接收 T[]
  • 使用 (item: T) => string 生成文本;
  • 选中时发出 T
  • 父组件可以使用具体的 User 类型接收选中项。

在支持泛型 <script setup> 的 Vue 版本中,可以写成:

<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{
  rows: T[]
  label: (row: T) => string
}>()

const emit = defineEmits<{
  select: [row: T]
}>()
</script>

<template>
  <ul>
    <li
      v-for="row in rows"
      :key="row.id"
    >
      <button type="button" @click="emit('select', row)">
        {{ label(row) }}
      </button>
    </li>
  </ul>
</template>

这里的约束 T extends { id: string | number } 表示:泛型项至少必须拥有可作为键的 id

父组件:

<script setup lang="ts">
import GenericList from './GenericList.vue'

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

const users: User[] = [
  { id: 1, name: 'Ada', email: 'ada@example.com' },
  { id: 2, name: 'Lin', email: 'lin@example.com' },
]

function showUser(user: User) {
  console.log(user.email)
}
</script>

<template>
  <GenericList
    :rows="users"
    :label="user => user.name"
    @select="showUser"
  />
</template>

理想的类型推导路径是:

rows: User[]
    ↓
推导 T = User
    ↓
label: (row: User) => string
    ↓
select 事件参数: User

这比把组件写成 rows: unknown[] 更有用,因为 select 事件仍然保留了具体元素类型。

7.2 泛型约束的必要性

下面的组件没有约束:

<script setup lang="ts" generic="T">
const props = defineProps<{
  rows: T[]
}>()
</script>

<template>
  <div v-for="row in rows" :key="row.id">
    {{ row }}
  </div>
</template>

T 可以是 stringnumber 或任意对象。模板使用 row.id 没有类型依据,因此这是错误的设计。

有两种修复方向:

一是增加约束:

<script setup lang="ts" generic="T extends { id: string | number }">

二是不要求组件内部知道 ID,而由调用者提供键函数:

<script setup lang="ts" generic="T">
const props = defineProps<{
  rows: T[]
  getKey: (row: T) => string | number
}>()
</script>

<template>
  <div
    v-for="row in rows"
    :key="getKey(row)"
  >
    {{ row }}
  </div>
</template>

两种设计分别表达不同的业务契约:

  • 所有数据类型都保证有 id
  • 数据类型没有统一字段,组件通过函数获得键。

不要为了让模板通过检查而随意使用断言:

const id = (row as { id: number }).id

这个断言没有证明 row 真的有 id,只是关闭了当前检查。

7.3 泛型组件的边界

泛型组件适合表达以下关系:

输入项类型 = T
标签函数参数类型 = T
选中事件参数类型 = T
插槽 item 参数类型 = T

但它不是运行时类型系统。组件运行时不会知道 TUser 还是 Product,因为 TypeScript 类型会被擦除。

此外,模板中的泛型推导依赖 Vue 语言服务和 vue-tsc 的支持。编辑器能推导,不代表所有手写的 h()、动态组件或复杂包装器也能保持同等精度。将泛型组件经过 defineAsyncComponent、自定义高阶组件或不带类型的动态包装后,类型信息可能丢失。


八、模板检查不是 Vite 默认完成的工作

Vite 的主要职责是开发服务器、模块转换和生产构建。对 TypeScript 文件,Vite 通常进行转译,而不是完整执行 TypeScript 类型检查。生产构建成功不等于类型检查成功。

典型项目可以这样配置:

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "typecheck": "vue-tsc --noEmit",
    "preview": "vite preview"
  }
}

执行:

npm run typecheck

vue-tsc 会读取 .vue 文件中的:

  • <script setup lang="ts">
  • Props 和 Emits;
  • 模板表达式;
  • 组件属性;
  • 事件监听器;
  • 部分插槽和模板引用信息。

例如:

<script setup lang="ts">
const count = 1
</script>

<template>
  <button @click="count.toUpperCase()">
    {{ count }}
  </button>
</template>

countnumber,模板中调用 toUpperCase() 应当被报告为错误。

如果只执行:

vite build

构建过程可能仍然成功,因为这一步不一定执行完整的 TypeScript 语义检查。要把类型检查放入 CI,至少应运行:

npm run typecheck
npm run build

这两个命令检查的对象不同:

vue-tsc --noEmit
    ├─ TypeScript 类型
    ├─ Vue 单文件组件类型
    └─ 模板表达式和组件使用关系

vite build
    ├─ 模块解析
    ├─ SFC 编译
    ├─ 资源处理
    ├─ 打包
    └─ 生产构建产物

一个通过而另一个失败,并不矛盾。

8.1 编辑器中的语言服务

VS Code 等编辑器通常通过 Vue 官方语言工具提供 .vue 文件的类型提示和模板诊断。应使用与项目 Vue 版本匹配的 Vue Language Tools。

实践中可能遇到:

  • 编辑器显示无错误,但 CI 中 vue-tsc 报错;
  • CI 类型检查报错,但编辑器没有及时更新;
  • 依赖升级后模板推导行为变化;
  • tsconfig 没有把 .vue 文件或 src 目录纳入检查范围。

诊断时先确认:

npx vue-tsc --noEmit

再检查 tsconfiginclude、项目引用配置和依赖版本,而不是直接在模板中添加 as any


九、类型检查的对象:组件使用者和组件实现者

组件类型安全有两个方向。

9.1 检查组件使用者

<UserCard
  :user="user"
  :compact="true"
  @select="onSelect"
/>

这里检查:

  • user 是否符合 Props;
  • compact 是否是声明的 Prop;
  • onSelect 是否能接收事件参数;
  • 是否使用了不存在的事件名。

9.2 检查组件实现者

<script setup lang="ts">
interface User {
  id: number
  name: string
}

const props = defineProps<{
  user: User
}>()

const emit = defineEmits<{
  select: [id: number]
}>()

function selectUser() {
  emit('select', props.user.id)
}
</script>

这里检查:

  • props.user.id 是否确实为 number
  • emit 的参数是否为 number
  • 模板是否访问了不存在的字段。

只有两个方向都检查,组件契约才完整。单独给父组件代码加类型,不能保证子组件内部没有发出错误事件;单独检查子组件,也不能保证调用方传入了正确数据。


十、插槽也是组件 API

插槽不仅是“插入一段模板”,还可能向父组件提供作用域数据。

子组件:

<script setup lang="ts">
interface User {
  id: number
  name: string
}

defineProps<{
  users: User[]
}>()

defineSlots<{
  default?: (props: { user: User }) => any
  empty?: () => any
}>()
</script>

<template>
  <ul v-if="users.length > 0">
    <li v-for="user in users" :key="user.id">
      <slot :user="user">
        {{ user.name }}
      </slot>
    </li>
  </ul>

  <slot v-else name="empty">
    暂无用户
  </slot>
</template>

父组件:

<UserList :users="users">
  <template #default="{ user }">
    <strong>{{ user.name }}</strong>
  </template>

  <template #empty>
    没有可显示的用户
  </template>
</UserList>

defineSlots 用于给插槽声明类型。它主要依赖 Vue 语言工具进行模板推导;如果使用的 Vue 版本或工具链较旧,可能只能获得部分检查能力。

插槽契约的价值在于避免父组件假设错误:

<template #default="{ user }">
  {{ user.email.toUpperCase() }}
</template>

如果 User 没有 email,模板检查应当报告错误。插槽参数不是组件内部私有变量,它属于组件公开 API。


十一、透传属性与声明边界

Vue 会把未被组件声明为 Props 或 Emits 的属性,通常透传到组件根节点。比如:

<MyButton
  id="save-button"
  class="primary"
  data-test="save"
/>

如果 MyButton 没有声明这些属性,它们可能被应用到根元素。

可以使用 $attrsuseAttrs 控制透传:

<script setup lang="ts">
defineOptions({
  inheritAttrs: false,
})
</script>

<template>
  <div class="button-wrapper">
    <button class="button" v-bind="$attrs">
      <slot />
    </button>
  </div>
</template>

这里的行为是:

父组件属性
    │
    ├─ 已声明 Prop:进入 props
    ├─ 已声明组件事件:作为监听器处理
    └─ 其他属性:进入 $attrs

透传属性的类型边界没有 Props 那么严格。$attrs 是动态属性集合,适合处理 classstyleiddata-* 和 ARIA 属性等 DOM 边界数据,但不应把它当成任意业务 API 的类型声明。

例如,一个按钮组件如果要求:

variant: 'primary' | 'danger'

就应把 variant 声明为 Props,而不是依靠 $attrs.variant。否则:

  • 拼写错误无法被组件契约捕获;
  • 业务属性可能被错误地透传给 DOM;
  • 原生元素不会自动理解这个属性。

多根节点组件的透传行为也需要显式处理,因为 Vue 无法总是推断应该把未声明属性放到哪个根节点。此时应在目标节点上使用 v-bind="$attrs"


十二、公开实例 API 与 defineExpose

<script setup> 中声明的变量默认不会作为组件实例的公开 API 暴露给父组件。若父组件需要通过模板引用调用子组件方法,应显式使用 defineExpose

子组件:

<script setup lang="ts">
import { ref } from 'vue'

const input = ref<HTMLInputElement | null>(null)

function focus() {
  input.value?.focus()
}

defineExpose({
  focus,
})
</script>

<template>
  <input ref="input">
</template>

父组件:

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import SearchInput from './SearchInput.vue'

const searchInput = ref<InstanceType<typeof SearchInput> | null>(null)

onMounted(() => {
  searchInput.value?.focus()
})
</script>

<template>
  <SearchInput ref="searchInput" />
</template>

这里需要区分两个引用:

searchInput
    └─ 子组件实例引用

input
    └─ 子组件内部的原生 input 引用

父组件只能调用 defineExpose 暴露的 focus,不能直接访问子组件内部的 input

如果组件没有暴露 focus

searchInput.value?.focus()

应当无法通过公开实例类型检查。

模板引用的自动推导能力会随 Vue Language Tools 和 Vue 版本变化。Vue 3.5 引入了 useTemplateRef,可以在部分场景下改善模板引用类型推导;若项目需要兼容较早版本,使用显式的 ref<... | null>(null) 是更通用的写法。

模板引用在挂载前为 null,组件卸载后也可能再次失效。因此不能写成:

searchInput.value.focus()

除非已经通过生命周期或条件判断证明它不为空。即使 TypeScript 被断言压制,运行时仍可能因为组件尚未挂载而抛出异常。


十三、组件契约中的状态与单向数据流

Props 应当被视为父组件拥有的数据。子组件可以读取,但不应直接修改:

const props = defineProps<{
  count: number
}>()

// props.count++ // 错误:Props 是只读的

正确的数据流是:

父状态 count
    │
    ├─ :count
    ▼
子组件 props.count
    │
    ├─ 用户操作
    ▼
emit('update:count', nextCount)
    │
    ▼
父状态 count 更新

完整示例:

<script setup lang="ts">
const props = defineProps<{
  count: number
}>()

const emit = defineEmits<{
  'update:count': [value: number]
}>()

function increment() {
  emit('update:count', props.count + 1)
}
</script>

<template>
  <button type="button" @click="increment">
    {{ count }}
  </button>
</template>

父组件:

<script setup lang="ts">
import { ref } from 'vue'
import Counter from './Counter.vue'

const count = ref(0)
</script>

<template>
  <Counter
    :count="count"
    @update:count="count = $event"
  />
</template>

如果子组件同时维护一个与 Prop 同步的本地副本,就引入了两个状态源:

父组件 count
子组件 localCount

此时需要定义同步规则:

  • 父组件变化时是否覆盖本地值;
  • 子组件修改时何时通知父组件;
  • 异步保存失败时回滚哪一个值;
  • 组件卸载后是否取消未完成操作。

类型系统只能检查值的类型,不能自动证明状态同步逻辑正确。双向绑定越复杂,越应该明确谁拥有状态、谁只负责派发意图。


十四、异步状态和错误类型

组件通常不仅有“数据”,还有加载、成功和失败状态。一个常见错误是只声明成功数据:

const user = ref<User | null>(null)

这无法表达请求正在加载还是已经失败。更明确的状态模型是:

type LoadState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

使用:

const state = ref<LoadState<User>>({ status: 'idle' })

async function loadUser() {
  state.value = { status: 'loading' }

  try {
    const response = await fetch('/api/user')

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }

    const data: unknown = await response.json()

    if (!isUser(data)) {
      throw new Error('响应数据格式无效')
    }

    state.value = {
      status: 'success',
      data,
    }
  } catch (error) {
    state.value = {
      status: 'error',
      error: error instanceof Error
        ? error
        : new Error('未知错误'),
    }
  }
}

模板中可以根据判别字段缩小类型:

<template>
  <p v-if="state.status === 'idle'">
    尚未加载
  </p>

  <p v-else-if="state.status === 'loading'">
    加载中
  </p>

  <p v-else-if="state.status === 'error'">
    {{ state.error.message }}
  </p>

  <p v-else>
    {{ state.data.name }}
  </p>
</template>

这里的 status 是判别字段。TypeScript 根据它推导当前分支中有哪些字段可用。相比大量非空断言:

state.data!.name

判别联合更能表达真实状态,错误路径也不会被隐藏。

并发请求还会引入竞态:

请求 A 开始
请求 B 开始
请求 B 先返回,状态变为 B
请求 A 后返回,状态又被覆盖为 A

类型检查无法解决“旧请求覆盖新请求”的时序问题。需要使用请求序号、AbortController 或请求库提供的取消机制。例如:

let requestId = 0

async function loadUser() {
  const currentId = ++requestId
  state.value = { status: 'loading' }

  try {
    const response = await fetch('/api/user')
    const data: unknown = await response.json()

    if (!isUser(data)) {
      throw new Error('无效用户数据')
    }

    if (currentId !== requestId) {
      return
    }

    state.value = {
      status: 'success',
      data,
    }
  } catch (error) {
    if (currentId !== requestId) {
      return
    }

    state.value = {
      status: 'error',
      error: error instanceof Error ? error : new Error('加载失败'),
    }
  }
}

requestId 只解决当前函数实例中的结果覆盖问题;如果组件卸载后仍有异步任务,还需要在适当时机取消请求或忽略结果。


十五、一个完整的泛型组件示例

下面把 Props、Emits、泛型和模板检查放在一个可运行的组件中。

DataTable.vue

<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{
  rows: T[]
  columns: Array<{
    key: string
    title: string
    render: (row: T) => string
  }>
  loading?: boolean
}>()

const emit = defineEmits<{
  rowClick: [row: T]
}>()
</script>

<template>
  <div class="data-table">
    <p v-if="loading">加载中...</p>

    <table v-else>
      <thead>
        <tr>
          <th
            v-for="column in columns"
            :key="column.key"
          >
            {{ column.title }}
          </th>
        </tr>
      </thead>

      <tbody>
        <tr
          v-for="row in rows"
          :key="row.id"
          @click="emit('rowClick', row)"
        >
          <td
            v-for="column in columns"
            :key="column.key"
          >
            {{ column.render(row) }}
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

父组件:

<script setup lang="ts">
import DataTable from './DataTable.vue'

interface Product {
  id: number
  name: string
  price: number
}

const products: Product[] = [
  { id: 1, name: 'Keyboard', price: 99 },
  { id: 2, name: 'Mouse', price: 49 },
]

const columns = [
  {
    key: 'name',
    title: '名称',
    render: (product: Product) => product.name,
  },
  {
    key: 'price',
    title: '价格',
    render: (product: Product) => `¥${product.price}`,
  },
]

function openProduct(product: Product) {
  console.log(product.id)
}
</script>

<template>
  <DataTable
    :rows="products"
    :columns="columns"
    @row-click="openProduct"
  />
</template>

类型关系如下:

products: Product[]
    ↓
泛型组件推导 T = Product
    ↓
columns.render: (product: Product) => string
    ↓
rowClick: (product: Product) => void

错误示例:

const invalidColumns = [
  {
    key: 'price',
    title: '价格',
    render: (product: Product) => product.price.toUpperCase(),
  },
]

pricenumber,不能调用 toUpperCase。这是组件外部配置的错误,模板检查和普通 TypeScript 检查都应该能够发现。

另一个错误:

function openProduct(id: string) {
  console.log(id)
}

如果语言工具正确推导了泛型事件,row-click 传入的是 Product,而不是 string。在复杂包装组件或动态组件场景中,如果推导没有保留下来,应检查组件导出方式、语言工具版本和是否引入了 any


十六、常见失败方式及其真实原因

16.1 把所有数据写成 any

const props = defineProps<{
  data: any
}>()

短期内模板错误减少,长期结果却是:

  • 字段拼写错误不再报告;
  • 事件参数失去具体类型;
  • 重构无法得到可靠反馈;
  • 外部数据的非法形状直接进入视图。

如果暂时无法描述完整数据,优先使用 unknown,然后在边界进行收窄:

const props = defineProps<{
  data: unknown
}>()

这会迫使组件明确处理数据形状。

16.2 用断言替代转换

const value = input.value as number

这不会把字符串转换成数字。正确的转换应显式进行:

const value = Number(input.value)

if (!Number.isFinite(value)) {
  throw new Error('输入不是有效数字')
}

断言适用于 TypeScript 已经无法从控制流推断、但程序员确实掌握额外事实的场景;它不应被当成运行时转换函数。

16.3 只检查 .ts,不检查 .vue

如果脚本执行:

tsc --noEmit

但项目配置没有通过 Vue 语言工具处理 .vue 文件,模板中的错误可能不会被检查。Vue 项目通常应使用:

vue-tsc --noEmit

而不是假设普通 tsc 会自动理解单文件组件模板。

16.4 认为 Vite 构建成功就代表类型正确

Vite 关注构建,不负责替代完整类型检查。应在 CI 中分别验证:

npm run typecheck
npm run build

若类型检查失败:

  1. 先读取第一个真实错误;
  2. 确认错误发生在 Props、Emits、模板还是依赖类型;
  3. 检查是否存在 any、断言或错误的 tsconfig 排除规则;
  4. 不要先通过关闭检查解决问题。

16.5 把组件事件写成字符串

emit('svae', value)

如果 Emits 没有类型声明,事件名拼写错误可能只能在人工测试中发现。声明事件后,事件名本身也成为可检查的联合类型。

16.6 误以为 Props 类型能验证后端数据

const props = defineProps<{
  user: User
}>()

这只能检查父组件传入的表达式在 TypeScript 中是否被认为是 User。如果父组件先做了:

const user = response.json() as User

那么错误数据已经绕过了类型系统。API 响应仍需要运行时校验、后端契约生成或可靠的解析器。


十七、如何验证一个组件契约是否真的成立

可以按照数据流而不是文件数量进行验证。

第一步:确认输入边界

列出组件接收的所有数据:

Props
v-model
Slots
Attrs

检查每个业务字段是否有明确类型,是否区分了可选、null 和默认值。

第二步:确认输出边界

列出组件可能产生的外部影响:

Emits
v-model 更新
defineExpose 暴露方法

为每个事件声明事件名和参数元组,避免通过无类型字符串发送事件。

第三步:检查模板

运行:

npx vue-tsc --noEmit

确认以下错误可以被捕获:

  • 不存在的 Prop;
  • 错误的 Prop 类型;
  • 不存在的事件;
  • 错误的事件参数;
  • 模板访问不存在的字段;
  • nullundefined 未处理;
  • 插槽参数使用错误。

第四步:检查运行时边界

对以下数据不要只依赖 TypeScript:

  • fetch().json()
  • localStorage.getItem()
  • URL 查询参数;
  • 用户输入;
  • postMessage
  • 第三方 SDK 返回值;
  • 服务端注入数据。

这些值进入应用后,应先转成可信的内部类型。

第五步:检查生产脚本

推荐至少保证:

{
  "scripts": {
    "typecheck": "vue-tsc --noEmit",
    "build": "npm run typecheck && vite build"
  }
}

如果团队不希望 build 隐式包含类型检查,也可以在 CI 中按顺序执行两个独立步骤,但不能只依赖开发服务器反馈。


十八、规范保证、实现能力与工程取舍

需要明确区分三个层次。

Vue 和 TypeScript 能保证的部分

在类型工具正确配置、代码没有绕过检查的前提下,可以静态检查:

  • Props 的声明和使用;
  • Emits 的事件名和参数;
  • v-model 的值及更新事件;
  • 组件公开实例类型;
  • 许多模板表达式;
  • 泛型参数之间的静态关系。

不能仅靠类型保证的部分

TypeScript 不会自动保证:

  • 后端返回数据真实符合接口;
  • 用户输入内容合法;
  • 异步请求不会竞态;
  • 组件卸载后异步回调不会执行;
  • 事件一定被父组件监听;
  • 业务状态转换一定完整;
  • DOM 运行时行为符合设计。

版本敏感能力

以下能力应根据项目实际版本确认:

  • <script setup> 的泛型组件支持;
  • defineSlots
  • defineModel
  • Vue 3.5 中的 useTemplateRef
  • Vue Language Tools 与 vue-tsc 对模板和泛型推导的具体支持。

不要仅因为编辑器当前能推导,就假设旧版本构建链也具备相同能力。升级 Vue、vue-tsc 或语言工具后,应重新运行类型检查和构建,并覆盖泛型组件、插槽、模板引用和 v-model 等关键契约。

一个可靠的 Vue TypeScript 组件,不是“所有地方都写上类型”,而是让数据从运行时边界进入可信状态,再通过 Props、Emits、Slots、v-model 和公开实例形成明确的数据流,并让 vue-tsc 持续检查这些关系。泛型用于保留多个输入输出之间的类型关联,模板检查用于覆盖 .vue 文件中的表达式,而运行时校验负责弥补 TypeScript 类型擦除后的现实边界。只有三者同时存在,组件 API 才真正具有可维护的契约。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。